This is page 2 of 5. Use http://codebase.md/portainer/portainer-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursor
│ └── rules
│ └── integration-test.mdc
├── .github
│ └── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CLAUDE.md
├── cloc.sh
├── cmd
│ ├── portainer-mcp
│ │ └── mcp.go
│ └── token-count
│ └── token.go
├── docs
│ ├── clients_and_models.md
│ ├── design
│ │ ├── 202503-1-external-tools-file.md
│ │ ├── 202503-2-tools-vs-mcp-resources.md
│ │ ├── 202503-3-specific-update-tools.md
│ │ ├── 202504-1-embedded-tools-yaml.md
│ │ ├── 202504-2-tools-yaml-versioning.md
│ │ ├── 202504-3-portainer-version-compatibility.md
│ │ └── 202504-4-read-only-mode.md
│ └── design_summary.md
├── go.mod
├── go.sum
├── internal
│ ├── k8sutil
│ │ ├── stripper_test.go
│ │ └── stripper.go
│ ├── mcp
│ │ ├── access_group_test.go
│ │ ├── access_group.go
│ │ ├── docker_test.go
│ │ ├── docker.go
│ │ ├── environment_test.go
│ │ ├── environment.go
│ │ ├── group_test.go
│ │ ├── group.go
│ │ ├── kubernetes_test.go
│ │ ├── kubernetes.go
│ │ ├── mocks_test.go
│ │ ├── schema_test.go
│ │ ├── schema.go
│ │ ├── server_test.go
│ │ ├── server.go
│ │ ├── settings_test.go
│ │ ├── settings.go
│ │ ├── stack_test.go
│ │ ├── stack.go
│ │ ├── tag_test.go
│ │ ├── tag.go
│ │ ├── team_test.go
│ │ ├── team.go
│ │ ├── testdata
│ │ │ ├── invalid_tools.yaml
│ │ │ └── valid_tools.yaml
│ │ ├── user_test.go
│ │ ├── user.go
│ │ ├── utils_test.go
│ │ └── utils.go
│ └── tooldef
│ ├── tooldef_test.go
│ ├── tooldef.go
│ └── tools.yaml
├── LICENSE
├── Makefile
├── pkg
│ ├── portainer
│ │ ├── client
│ │ │ ├── access_group_test.go
│ │ │ ├── access_group.go
│ │ │ ├── client_test.go
│ │ │ ├── client.go
│ │ │ ├── docker_test.go
│ │ │ ├── docker.go
│ │ │ ├── environment_test.go
│ │ │ ├── environment.go
│ │ │ ├── group_test.go
│ │ │ ├── group.go
│ │ │ ├── kubernetes_test.go
│ │ │ ├── kubernetes.go
│ │ │ ├── mocks_test.go
│ │ │ ├── settings_test.go
│ │ │ ├── settings.go
│ │ │ ├── stack_test.go
│ │ │ ├── stack.go
│ │ │ ├── tag_test.go
│ │ │ ├── tag.go
│ │ │ ├── team_test.go
│ │ │ ├── team.go
│ │ │ ├── user_test.go
│ │ │ ├── user.go
│ │ │ ├── version_test.go
│ │ │ └── version.go
│ │ ├── models
│ │ │ ├── access_group_test.go
│ │ │ ├── access_group.go
│ │ │ ├── access_policy_test.go
│ │ │ ├── access_policy.go
│ │ │ ├── docker.go
│ │ │ ├── environment_test.go
│ │ │ ├── environment.go
│ │ │ ├── group_test.go
│ │ │ ├── group.go
│ │ │ ├── kubernetes.go
│ │ │ ├── settings_test.go
│ │ │ ├── settings.go
│ │ │ ├── stack_test.go
│ │ │ ├── stack.go
│ │ │ ├── tag_test.go
│ │ │ ├── tag.go
│ │ │ ├── team_test.go
│ │ │ ├── team.go
│ │ │ ├── user_test.go
│ │ │ └── user.go
│ │ └── utils
│ │ ├── utils_test.go
│ │ └── utils.go
│ └── toolgen
│ ├── param_test.go
│ ├── param.go
│ ├── yaml_test.go
│ └── yaml.go
├── README.md
├── tests
│ └── integration
│ ├── access_group_test.go
│ ├── containers
│ │ └── portainer.go
│ ├── docker_test.go
│ ├── environment_test.go
│ ├── group_test.go
│ ├── helpers
│ │ └── test_env.go
│ ├── server_test.go
│ ├── settings_test.go
│ ├── stack_test.go
│ ├── tag_test.go
│ ├── team_test.go
│ └── user_test.go
└── token.sh
```
# Files
--------------------------------------------------------------------------------
/internal/mcp/stack.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/portainer/portainer-mcp/pkg/toolgen"
11 | )
12 |
13 | func (s *PortainerMCPServer) AddStackFeatures() {
14 | s.addToolIfExists(ToolListStacks, s.HandleGetStacks())
15 | s.addToolIfExists(ToolGetStackFile, s.HandleGetStackFile())
16 |
17 | if !s.readOnly {
18 | s.addToolIfExists(ToolCreateStack, s.HandleCreateStack())
19 | s.addToolIfExists(ToolUpdateStack, s.HandleUpdateStack())
20 | }
21 | }
22 |
23 | func (s *PortainerMCPServer) HandleGetStacks() server.ToolHandlerFunc {
24 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
25 | stacks, err := s.cli.GetStacks()
26 | if err != nil {
27 | return mcp.NewToolResultErrorFromErr("failed to get stacks", err), nil
28 | }
29 |
30 | data, err := json.Marshal(stacks)
31 | if err != nil {
32 | return mcp.NewToolResultErrorFromErr("failed to marshal stacks", err), nil
33 | }
34 |
35 | return mcp.NewToolResultText(string(data)), nil
36 | }
37 | }
38 |
39 | func (s *PortainerMCPServer) HandleGetStackFile() server.ToolHandlerFunc {
40 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
41 | parser := toolgen.NewParameterParser(request)
42 |
43 | id, err := parser.GetInt("id", true)
44 | if err != nil {
45 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
46 | }
47 |
48 | stackFile, err := s.cli.GetStackFile(id)
49 | if err != nil {
50 | return mcp.NewToolResultErrorFromErr("failed to get stack file", err), nil
51 | }
52 |
53 | return mcp.NewToolResultText(stackFile), nil
54 | }
55 | }
56 |
57 | func (s *PortainerMCPServer) HandleCreateStack() server.ToolHandlerFunc {
58 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
59 | parser := toolgen.NewParameterParser(request)
60 |
61 | name, err := parser.GetString("name", true)
62 | if err != nil {
63 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
64 | }
65 |
66 | file, err := parser.GetString("file", true)
67 | if err != nil {
68 | return mcp.NewToolResultErrorFromErr("invalid file parameter", err), nil
69 | }
70 |
71 | environmentGroupIds, err := parser.GetArrayOfIntegers("environmentGroupIds", true)
72 | if err != nil {
73 | return mcp.NewToolResultErrorFromErr("invalid environmentGroupIds parameter", err), nil
74 | }
75 |
76 | id, err := s.cli.CreateStack(name, file, environmentGroupIds)
77 | if err != nil {
78 | return mcp.NewToolResultErrorFromErr("error creating stack", err), nil
79 | }
80 |
81 | return mcp.NewToolResultText(fmt.Sprintf("Stack created successfully with ID: %d", id)), nil
82 | }
83 | }
84 |
85 | func (s *PortainerMCPServer) HandleUpdateStack() server.ToolHandlerFunc {
86 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
87 | parser := toolgen.NewParameterParser(request)
88 |
89 | id, err := parser.GetInt("id", true)
90 | if err != nil {
91 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
92 | }
93 |
94 | file, err := parser.GetString("file", true)
95 | if err != nil {
96 | return mcp.NewToolResultErrorFromErr("invalid file parameter", err), nil
97 | }
98 |
99 | environmentGroupIds, err := parser.GetArrayOfIntegers("environmentGroupIds", true)
100 | if err != nil {
101 | return mcp.NewToolResultErrorFromErr("invalid environmentGroupIds parameter", err), nil
102 | }
103 |
104 | err = s.cli.UpdateStack(id, file, environmentGroupIds)
105 | if err != nil {
106 | return mcp.NewToolResultErrorFromErr("failed to update stack", err), nil
107 | }
108 |
109 | return mcp.NewToolResultText("Stack updated successfully"), nil
110 | }
111 | }
112 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/settings_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
8 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestGetSettings(t *testing.T) {
13 | tests := []struct {
14 | name string
15 | mockSettings *apimodels.PortainereeSettings
16 | mockError error
17 | expected models.PortainerSettings
18 | expectedError bool
19 | }{
20 | {
21 | name: "successful retrieval - internal auth",
22 | mockSettings: &apimodels.PortainereeSettings{
23 | AuthenticationMethod: 1, // internal
24 | EnableEdgeComputeFeatures: true,
25 | Edge: &apimodels.PortainereeEdge{
26 | TunnelServerAddress: "tunnel.example.com",
27 | },
28 | },
29 | expected: models.PortainerSettings{
30 | Authentication: struct {
31 | Method string `json:"method"`
32 | }{
33 | Method: models.AuthenticationMethodInternal,
34 | },
35 | Edge: struct {
36 | Enabled bool `json:"enabled"`
37 | ServerURL string `json:"server_url"`
38 | }{
39 | Enabled: true,
40 | ServerURL: "tunnel.example.com",
41 | },
42 | },
43 | },
44 | {
45 | name: "successful retrieval - ldap auth",
46 | mockSettings: &apimodels.PortainereeSettings{
47 | AuthenticationMethod: 2, // ldap
48 | EnableEdgeComputeFeatures: false,
49 | Edge: &apimodels.PortainereeEdge{
50 | TunnelServerAddress: "tunnel2.example.com",
51 | },
52 | },
53 | expected: models.PortainerSettings{
54 | Authentication: struct {
55 | Method string `json:"method"`
56 | }{
57 | Method: models.AuthenticationMethodLDAP,
58 | },
59 | Edge: struct {
60 | Enabled bool `json:"enabled"`
61 | ServerURL string `json:"server_url"`
62 | }{
63 | Enabled: false,
64 | ServerURL: "tunnel2.example.com",
65 | },
66 | },
67 | },
68 | {
69 | name: "successful retrieval - oauth auth",
70 | mockSettings: &apimodels.PortainereeSettings{
71 | AuthenticationMethod: 3, // oauth
72 | EnableEdgeComputeFeatures: true,
73 | Edge: &apimodels.PortainereeEdge{
74 | TunnelServerAddress: "tunnel3.example.com",
75 | },
76 | },
77 | expected: models.PortainerSettings{
78 | Authentication: struct {
79 | Method string `json:"method"`
80 | }{
81 | Method: models.AuthenticationMethodOAuth,
82 | },
83 | Edge: struct {
84 | Enabled bool `json:"enabled"`
85 | ServerURL string `json:"server_url"`
86 | }{
87 | Enabled: true,
88 | ServerURL: "tunnel3.example.com",
89 | },
90 | },
91 | },
92 | {
93 | name: "successful retrieval - unknown auth",
94 | mockSettings: &apimodels.PortainereeSettings{
95 | AuthenticationMethod: 0, // unknown
96 | EnableEdgeComputeFeatures: false,
97 | Edge: &apimodels.PortainereeEdge{
98 | TunnelServerAddress: "tunnel4.example.com",
99 | },
100 | },
101 | expected: models.PortainerSettings{
102 | Authentication: struct {
103 | Method string `json:"method"`
104 | }{
105 | Method: models.AuthenticationMethodUnknown,
106 | },
107 | Edge: struct {
108 | Enabled bool `json:"enabled"`
109 | ServerURL string `json:"server_url"`
110 | }{
111 | Enabled: false,
112 | ServerURL: "tunnel4.example.com",
113 | },
114 | },
115 | },
116 | {
117 | name: "get settings error",
118 | mockError: errors.New("failed to get settings"),
119 | expectedError: true,
120 | },
121 | }
122 |
123 | for _, tt := range tests {
124 | t.Run(tt.name, func(t *testing.T) {
125 | mockAPI := new(MockPortainerAPI)
126 | mockAPI.On("GetSettings").Return(tt.mockSettings, tt.mockError)
127 |
128 | client := &PortainerClient{cli: mockAPI}
129 |
130 | settings, err := client.GetSettings()
131 |
132 | if tt.expectedError {
133 | assert.Error(t, err)
134 | return
135 | }
136 | assert.NoError(t, err)
137 | assert.Equal(t, tt.expected, settings)
138 | mockAPI.AssertExpectations(t)
139 | })
140 | }
141 | }
142 |
```
--------------------------------------------------------------------------------
/tests/integration/user_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
8 | "github.com/portainer/portainer-mcp/internal/mcp"
9 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
10 | "github.com/portainer/portainer-mcp/tests/integration/helpers"
11 |
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | const (
17 | testUsername = "test-mcp-user"
18 | testUserPassword = "testpassword"
19 | userRoleStandard = 2 // Portainer API role ID for Standard User
20 | )
21 |
22 | // prepareUserManagementTestEnvironment creates a test user and returns its ID
23 | func prepareUserManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) int {
24 | testUserID, err := env.RawClient.CreateUser(testUsername, testUserPassword, userRoleStandard)
25 | require.NoError(t, err, "Failed to create test user via raw client")
26 | return int(testUserID)
27 | }
28 |
29 | // TestUserManagement is an integration test suite that verifies the complete
30 | // lifecycle of user management in Portainer MCP. It tests user listing
31 | // and role updates.
32 | func TestUserManagement(t *testing.T) {
33 | env := helpers.NewTestEnv(t)
34 | defer env.Cleanup(t)
35 |
36 | testUserID := prepareUserManagementTestEnvironment(t, env)
37 |
38 | // Subtest: User Listing
39 | // Verifies listing users (admin + test user) via MCP handler and compares with direct API call.
40 | t.Run("User Listing", func(t *testing.T) {
41 | handler := env.MCPServer.HandleGetUsers()
42 | result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
43 | require.NoError(t, err, "Failed to get users via MCP handler")
44 |
45 | require.Len(t, result.Content, 1, "Expected exactly one content block in the result")
46 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
47 | require.True(t, ok, "Expected text content in MCP response")
48 |
49 | var retrievedUsers []models.User
50 | err = json.Unmarshal([]byte(textContent.Text), &retrievedUsers)
51 | require.NoError(t, err, "Failed to unmarshal retrieved users")
52 |
53 | require.Equal(t, len(retrievedUsers), 2, "Expected 2 users (admin and test user)")
54 |
55 | rawUsers, err := env.RawClient.ListUsers()
56 | require.NoError(t, err, "Failed to get users directly via client for comparison")
57 |
58 | expectedConvertedUsers := make([]models.User, 0, len(rawUsers))
59 | for _, rawUser := range rawUsers {
60 | expectedConvertedUsers = append(expectedConvertedUsers, models.ConvertToUser(rawUser))
61 | }
62 |
63 | assert.ElementsMatch(t, expectedConvertedUsers, retrievedUsers, "Mismatch between MCP handler users and converted client users")
64 | })
65 |
66 | // Subtest: User Role Update
67 | // Verifies updating the test user's role from standard to admin via the MCP handler.
68 | t.Run("User Role Update", func(t *testing.T) {
69 | handler := env.MCPServer.HandleUpdateUserRole()
70 |
71 | newRole := models.UserRoleAdmin
72 | updateRequest := mcp.CreateMCPRequest(map[string]any{
73 | "id": float64(testUserID),
74 | "role": newRole,
75 | })
76 |
77 | result, err := handler(env.Ctx, updateRequest)
78 | require.NoError(t, err, "Failed to update test user role to '%s' via MCP handler", newRole)
79 |
80 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
81 | require.True(t, ok, "Expected text content in MCP response for role update")
82 | assert.Contains(t, textContent.Text, "User updated successfully", "Success message mismatch for role update")
83 |
84 | rawUpdatedUser, err := env.RawClient.GetUser(testUserID)
85 | require.NoError(t, err, "Failed to get test user directly via client after role update")
86 |
87 | convertedUpdatedUser := models.ConvertToUser(rawUpdatedUser)
88 | assert.Equal(t, newRole, convertedUpdatedUser.Role, "User role was not updated to '%s' after conversion check", newRole)
89 | })
90 | }
91 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/client.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/portainer/client-api-go/v2/client"
7 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
8 | )
9 |
10 | // PortainerAPIClient defines the interface for the underlying Portainer API client
11 | type PortainerAPIClient interface {
12 | ListEdgeGroups() ([]*apimodels.EdgegroupsDecoratedEdgeGroup, error)
13 | CreateEdgeGroup(name string, environmentIds []int64) (int64, error)
14 | UpdateEdgeGroup(id int64, name *string, environmentIds *[]int64, tagIds *[]int64) error
15 | ListEdgeStacks() ([]*apimodels.PortainereeEdgeStack, error)
16 | CreateEdgeStack(name string, file string, environmentGroupIds []int64) (int64, error)
17 | UpdateEdgeStack(id int64, file string, environmentGroupIds []int64) error
18 | GetEdgeStackFile(id int64) (string, error)
19 | ListEndpointGroups() ([]*apimodels.PortainerEndpointGroup, error)
20 | CreateEndpointGroup(name string, associatedEndpoints []int64) (int64, error)
21 | UpdateEndpointGroup(id int64, name *string, userAccesses *map[int64]string, teamAccesses *map[int64]string) error
22 | AddEnvironmentToEndpointGroup(groupId int64, environmentId int64) error
23 | RemoveEnvironmentFromEndpointGroup(groupId int64, environmentId int64) error
24 | ListEndpoints() ([]*apimodels.PortainereeEndpoint, error)
25 | GetEndpoint(id int64) (*apimodels.PortainereeEndpoint, error)
26 | UpdateEndpoint(id int64, tagIds *[]int64, userAccesses *map[int64]string, teamAccesses *map[int64]string) error
27 | GetSettings() (*apimodels.PortainereeSettings, error)
28 | ListTags() ([]*apimodels.PortainerTag, error)
29 | CreateTag(name string) (int64, error)
30 | ListTeams() ([]*apimodels.PortainerTeam, error)
31 | ListTeamMemberships() ([]*apimodels.PortainerTeamMembership, error)
32 | CreateTeam(name string) (int64, error)
33 | UpdateTeamName(id int, name string) error
34 | DeleteTeamMembership(id int) error
35 | CreateTeamMembership(teamId int, userId int) error
36 | ListUsers() ([]*apimodels.PortainereeUser, error)
37 | UpdateUserRole(id int, role int64) error
38 | GetVersion() (string, error)
39 | ProxyDockerRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error)
40 | ProxyKubernetesRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error)
41 | }
42 |
43 | // PortainerClient is a wrapper around the Portainer SDK client
44 | // that provides simplified access to Portainer API functionality.
45 | type PortainerClient struct {
46 | cli PortainerAPIClient
47 | }
48 |
49 | // ClientOption defines a function that configures a PortainerClient.
50 | type ClientOption func(*clientOptions)
51 |
52 | // clientOptions holds configuration options for the PortainerClient.
53 | type clientOptions struct {
54 | skipTLSVerify bool
55 | }
56 |
57 | // WithSkipTLSVerify configures whether to skip TLS certificate verification.
58 | // Setting this to true is not recommended for production environments.
59 | func WithSkipTLSVerify(skip bool) ClientOption {
60 | return func(o *clientOptions) {
61 | o.skipTLSVerify = skip
62 | }
63 | }
64 |
65 | // NewPortainerClient creates a new PortainerClient instance with the provided
66 | // server URL and authentication token.
67 | //
68 | // Parameters:
69 | // - serverURL: The base URL of the Portainer server
70 | // - token: The authentication token for API access
71 | // - opts: Optional configuration options for the client
72 | //
73 | // Returns:
74 | // - A configured PortainerClient ready for API operations
75 | func NewPortainerClient(serverURL string, token string, opts ...ClientOption) *PortainerClient {
76 | options := clientOptions{
77 | skipTLSVerify: false, // Default to secure TLS verification
78 | }
79 |
80 | for _, opt := range opts {
81 | opt(&options)
82 | }
83 |
84 | return &PortainerClient{
85 | cli: client.NewPortainerClient(serverURL, token, client.WithSkipTLSVerify(options.skipTLSVerify)),
86 | }
87 | }
88 |
```
--------------------------------------------------------------------------------
/tests/integration/tag_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
8 | "github.com/portainer/portainer-mcp/internal/mcp"
9 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
10 | "github.com/portainer/portainer-mcp/tests/integration/helpers"
11 | "github.com/stretchr/testify/assert"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | const (
16 | testTagName1 = "test-tag-integration-1"
17 | testTagName2 = "test-tag-integration-2"
18 | )
19 |
20 | // TestTagManagement is an integration test suite that verifies the create
21 | // and list operations for environment tags in Portainer MCP.
22 | func TestTagManagement(t *testing.T) {
23 | env := helpers.NewTestEnv(t)
24 | defer env.Cleanup(t)
25 |
26 | // Subtest: Tag Creation
27 | // Verifies that:
28 | // - A new tag can be created via the MCP handler.
29 | // - The handler response indicates success.
30 | // - The created tag exists in Portainer when checked directly via the Raw Client.
31 | t.Run("Tag Creation", func(t *testing.T) {
32 | handler := env.MCPServer.HandleCreateEnvironmentTag()
33 | request := mcp.CreateMCPRequest(map[string]any{
34 | "name": testTagName1,
35 | })
36 |
37 | result, err := handler(env.Ctx, request)
38 | require.NoError(t, err, "Failed to create tag via MCP handler")
39 |
40 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
41 | require.True(t, ok, "Expected text content in MCP response")
42 | // Just check for the success prefix, no need to parse ID here
43 | assert.Contains(t, textContent.Text, "Environment tag created successfully with ID:", "Success message prefix mismatch")
44 |
45 | // Verify by fetching the tag directly via the client and finding the created tag by name
46 | tag, err := env.RawClient.GetTagByName(testTagName1)
47 | require.NoError(t, err, "Failed to get tag directly via client after creation")
48 | assert.Equal(t, testTagName1, tag.Name, "Tag name mismatch")
49 | })
50 |
51 | // Subtest: Tag Listing
52 | // Verifies that:
53 | // - Tags can be listed via the MCP handler.
54 | // - The list includes previously created tags.
55 | // - The data structure returned by the handler matches the expected local model.
56 | // - Compares MCP handler output with direct client API call result after conversion.
57 | t.Run("Tag Listing", func(t *testing.T) {
58 | // Create another tag directly for listing comparison
59 | _, err := env.RawClient.CreateTag(testTagName2)
60 | require.NoError(t, err, "Failed to create second tag directly")
61 |
62 | handler := env.MCPServer.HandleGetEnvironmentTags()
63 | result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
64 | require.NoError(t, err, "Failed to get tags via MCP handler")
65 |
66 | require.Len(t, result.Content, 1, "Expected exactly one content block in the result")
67 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
68 | require.True(t, ok, "Expected text content in MCP response")
69 |
70 | // Unmarshal the result from the MCP handler
71 | var retrievedTags []models.EnvironmentTag
72 | err = json.Unmarshal([]byte(textContent.Text), &retrievedTags)
73 | require.NoError(t, err, "Failed to unmarshal retrieved tags")
74 |
75 | // Fetch tags directly via client
76 | rawTags, err := env.RawClient.ListTags()
77 | require.NoError(t, err, "Failed to get tags directly via client for comparison")
78 |
79 | // Convert the raw tags to the expected EnvironmentTag model
80 | expectedConvertedTags := make([]models.EnvironmentTag, 0, len(rawTags))
81 | for _, rawTag := range rawTags {
82 | expectedConvertedTags = append(expectedConvertedTags, models.ConvertTagToEnvironmentTag(rawTag))
83 | }
84 |
85 | // Compare the tags from MCP handler with the ones converted from the direct client call
86 | // Use ElementsMatch as the order might not be guaranteed.
87 | assert.ElementsMatch(t, expectedConvertedTags, retrievedTags, "Mismatch between MCP handler tags and converted client tags")
88 | })
89 | }
90 |
```
--------------------------------------------------------------------------------
/internal/mcp/environment.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 |
7 | "github.com/mark3labs/mcp-go/mcp"
8 | "github.com/mark3labs/mcp-go/server"
9 | "github.com/portainer/portainer-mcp/pkg/toolgen"
10 | )
11 |
12 | func (s *PortainerMCPServer) AddEnvironmentFeatures() {
13 | s.addToolIfExists(ToolListEnvironments, s.HandleGetEnvironments())
14 |
15 | if !s.readOnly {
16 | s.addToolIfExists(ToolUpdateEnvironmentTags, s.HandleUpdateEnvironmentTags())
17 | s.addToolIfExists(ToolUpdateEnvironmentUserAccesses, s.HandleUpdateEnvironmentUserAccesses())
18 | s.addToolIfExists(ToolUpdateEnvironmentTeamAccesses, s.HandleUpdateEnvironmentTeamAccesses())
19 | }
20 | }
21 |
22 | func (s *PortainerMCPServer) HandleGetEnvironments() server.ToolHandlerFunc {
23 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
24 | environments, err := s.cli.GetEnvironments()
25 | if err != nil {
26 | return mcp.NewToolResultErrorFromErr("failed to get environments", err), nil
27 | }
28 |
29 | data, err := json.Marshal(environments)
30 | if err != nil {
31 | return mcp.NewToolResultErrorFromErr("failed to marshal environments", err), nil
32 | }
33 |
34 | return mcp.NewToolResultText(string(data)), nil
35 | }
36 | }
37 |
38 | func (s *PortainerMCPServer) HandleUpdateEnvironmentTags() server.ToolHandlerFunc {
39 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
40 | parser := toolgen.NewParameterParser(request)
41 |
42 | id, err := parser.GetInt("id", true)
43 | if err != nil {
44 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
45 | }
46 |
47 | tagIds, err := parser.GetArrayOfIntegers("tagIds", true)
48 | if err != nil {
49 | return mcp.NewToolResultErrorFromErr("invalid tagIds parameter", err), nil
50 | }
51 |
52 | err = s.cli.UpdateEnvironmentTags(id, tagIds)
53 | if err != nil {
54 | return mcp.NewToolResultErrorFromErr("failed to update environment tags", err), nil
55 | }
56 |
57 | return mcp.NewToolResultText("Environment tags updated successfully"), nil
58 | }
59 | }
60 |
61 | func (s *PortainerMCPServer) HandleUpdateEnvironmentUserAccesses() server.ToolHandlerFunc {
62 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
63 | parser := toolgen.NewParameterParser(request)
64 |
65 | id, err := parser.GetInt("id", true)
66 | if err != nil {
67 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
68 | }
69 |
70 | userAccesses, err := parser.GetArrayOfObjects("userAccesses", true)
71 | if err != nil {
72 | return mcp.NewToolResultErrorFromErr("invalid userAccesses parameter", err), nil
73 | }
74 |
75 | userAccessesMap, err := parseAccessMap(userAccesses)
76 | if err != nil {
77 | return mcp.NewToolResultErrorFromErr("invalid user accesses", err), nil
78 | }
79 |
80 | err = s.cli.UpdateEnvironmentUserAccesses(id, userAccessesMap)
81 | if err != nil {
82 | return mcp.NewToolResultErrorFromErr("failed to update environment user accesses", err), nil
83 | }
84 |
85 | return mcp.NewToolResultText("Environment user accesses updated successfully"), nil
86 | }
87 | }
88 |
89 | func (s *PortainerMCPServer) HandleUpdateEnvironmentTeamAccesses() server.ToolHandlerFunc {
90 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
91 | parser := toolgen.NewParameterParser(request)
92 |
93 | id, err := parser.GetInt("id", true)
94 | if err != nil {
95 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
96 | }
97 |
98 | teamAccesses, err := parser.GetArrayOfObjects("teamAccesses", true)
99 | if err != nil {
100 | return mcp.NewToolResultErrorFromErr("invalid teamAccesses parameter", err), nil
101 | }
102 |
103 | teamAccessesMap, err := parseAccessMap(teamAccesses)
104 | if err != nil {
105 | return mcp.NewToolResultErrorFromErr("invalid team accesses", err), nil
106 | }
107 |
108 | err = s.cli.UpdateEnvironmentTeamAccesses(id, teamAccessesMap)
109 | if err != nil {
110 | return mcp.NewToolResultErrorFromErr("failed to update environment team accesses", err), nil
111 | }
112 |
113 | return mcp.NewToolResultText("Environment team accesses updated successfully"), nil
114 | }
115 | }
116 |
```
--------------------------------------------------------------------------------
/internal/mcp/schema.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import "slices"
4 |
5 | // Tool names as defined in the YAML file
6 | const (
7 | ToolCreateEnvironmentGroup = "createEnvironmentGroup"
8 | ToolListEnvironmentGroups = "listEnvironmentGroups"
9 | ToolUpdateEnvironmentGroup = "updateEnvironmentGroup"
10 | ToolCreateAccessGroup = "createAccessGroup"
11 | ToolListAccessGroups = "listAccessGroups"
12 | ToolUpdateAccessGroup = "updateAccessGroup"
13 | ToolAddEnvironmentToAccessGroup = "addEnvironmentToAccessGroup"
14 | ToolRemoveEnvironmentFromAccessGroup = "removeEnvironmentFromAccessGroup"
15 | ToolListEnvironments = "listEnvironments"
16 | ToolUpdateEnvironment = "updateEnvironment"
17 | ToolGetStackFile = "getStackFile"
18 | ToolCreateStack = "createStack"
19 | ToolListStacks = "listStacks"
20 | ToolUpdateStack = "updateStack"
21 | ToolCreateEnvironmentTag = "createEnvironmentTag"
22 | ToolListEnvironmentTags = "listEnvironmentTags"
23 | ToolCreateTeam = "createTeam"
24 | ToolListTeams = "listTeams"
25 | ToolUpdateTeamName = "updateTeamName"
26 | ToolUpdateTeamMembers = "updateTeamMembers"
27 | ToolListUsers = "listUsers"
28 | ToolUpdateUserRole = "updateUserRole"
29 | ToolGetSettings = "getSettings"
30 | ToolUpdateAccessGroupName = "updateAccessGroupName"
31 | ToolUpdateAccessGroupUserAccesses = "updateAccessGroupUserAccesses"
32 | ToolUpdateAccessGroupTeamAccesses = "updateAccessGroupTeamAccesses"
33 | ToolUpdateEnvironmentTags = "updateEnvironmentTags"
34 | ToolUpdateEnvironmentUserAccesses = "updateEnvironmentUserAccesses"
35 | ToolUpdateEnvironmentTeamAccesses = "updateEnvironmentTeamAccesses"
36 | ToolUpdateEnvironmentGroupName = "updateEnvironmentGroupName"
37 | ToolUpdateEnvironmentGroupEnvironments = "updateEnvironmentGroupEnvironments"
38 | ToolUpdateEnvironmentGroupTags = "updateEnvironmentGroupTags"
39 | ToolDockerProxy = "dockerProxy"
40 | ToolKubernetesProxy = "kubernetesProxy"
41 | ToolKubernetesProxyStripped = "getKubernetesResourceStripped"
42 | )
43 |
44 | // Access levels for users and teams
45 | const (
46 | // AccessLevelEnvironmentAdmin represents the environment administrator access level
47 | AccessLevelEnvironmentAdmin = "environment_administrator"
48 | // AccessLevelHelpdeskUser represents the helpdesk user access level
49 | AccessLevelHelpdeskUser = "helpdesk_user"
50 | // AccessLevelStandardUser represents the standard user access level
51 | AccessLevelStandardUser = "standard_user"
52 | // AccessLevelReadonlyUser represents the readonly user access level
53 | AccessLevelReadonlyUser = "readonly_user"
54 | // AccessLevelOperatorUser represents the operator user access level
55 | AccessLevelOperatorUser = "operator_user"
56 | )
57 |
58 | // User roles
59 | const (
60 | // UserRoleAdmin represents an admin user role
61 | UserRoleAdmin = "admin"
62 | // UserRoleUser represents a regular user role
63 | UserRoleUser = "user"
64 | // UserRoleEdgeAdmin represents an edge admin user role
65 | UserRoleEdgeAdmin = "edge_admin"
66 | )
67 |
68 | // All available access levels
69 | var AllAccessLevels = []string{
70 | AccessLevelEnvironmentAdmin,
71 | AccessLevelHelpdeskUser,
72 | AccessLevelStandardUser,
73 | AccessLevelReadonlyUser,
74 | AccessLevelOperatorUser,
75 | }
76 |
77 | // All available user roles
78 | var AllUserRoles = []string{
79 | UserRoleAdmin,
80 | UserRoleUser,
81 | UserRoleEdgeAdmin,
82 | }
83 |
84 | // isValidAccessLevel checks if a given string is a valid access level
85 | func isValidAccessLevel(access string) bool {
86 | return slices.Contains(AllAccessLevels, access)
87 | }
88 |
89 | // isValidUserRole checks if a given string is a valid user role
90 | func isValidUserRole(role string) bool {
91 | return slices.Contains(AllUserRoles, role)
92 | }
93 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/kubernetes_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "io"
7 | "net/http"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/portainer/client-api-go/v2/client"
12 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
13 | "github.com/stretchr/testify/assert"
14 | )
15 |
16 | func TestProxyKubernetesRequest(t *testing.T) {
17 | tests := []struct {
18 | name string
19 | opts models.KubernetesProxyRequestOptions
20 | mockResponse *http.Response
21 | mockError error
22 | expectedError bool
23 | expectedStatus int
24 | expectedRespBody string
25 | }{
26 | {
27 | name: "GET request with query parameters",
28 | opts: models.KubernetesProxyRequestOptions{
29 | EnvironmentID: 1,
30 | Method: "GET",
31 | Path: "/api/v1/pods",
32 | QueryParams: map[string]string{"namespace": "default", "labelSelector": "app=myapp"},
33 | },
34 | mockResponse: &http.Response{
35 | StatusCode: http.StatusOK,
36 | Body: io.NopCloser(strings.NewReader(`{"items": [{"metadata": {"name": "pod1"}}]}`)),
37 | },
38 | mockError: nil,
39 | expectedError: false,
40 | expectedStatus: http.StatusOK,
41 | expectedRespBody: `{"items": [{"metadata": {"name": "pod1"}}]}`,
42 | },
43 | {
44 | name: "POST request with custom headers and body",
45 | opts: models.KubernetesProxyRequestOptions{
46 | EnvironmentID: 2,
47 | Method: "POST",
48 | Path: "/api/v1/namespaces/default/services",
49 | Headers: map[string]string{"X-Custom-Header": "value1", "Content-Type": "application/json"},
50 | Body: bytes.NewBufferString(`{"apiVersion": "v1", "kind": "Service", "metadata": {"name": "my-service"}}`),
51 | },
52 | mockResponse: &http.Response{
53 | StatusCode: http.StatusCreated,
54 | Body: io.NopCloser(strings.NewReader(`{"metadata": {"name": "my-service"}}`)),
55 | },
56 | mockError: nil,
57 | expectedError: false,
58 | expectedStatus: http.StatusCreated,
59 | expectedRespBody: `{"metadata": {"name": "my-service"}}`,
60 | },
61 | {
62 | name: "API error",
63 | opts: models.KubernetesProxyRequestOptions{
64 | EnvironmentID: 3,
65 | Method: "GET",
66 | Path: "/version",
67 | },
68 | mockResponse: nil,
69 | mockError: errors.New("failed to proxy kubernetes request"),
70 | expectedError: true,
71 | expectedStatus: 0, // Not applicable
72 | expectedRespBody: "", // Not applicable
73 | },
74 | {
75 | name: "Request with no params, headers, or body",
76 | opts: models.KubernetesProxyRequestOptions{
77 | EnvironmentID: 4,
78 | Method: "GET",
79 | Path: "/healthz",
80 | },
81 | mockResponse: &http.Response{
82 | StatusCode: http.StatusOK,
83 | Body: io.NopCloser(strings.NewReader("ok")),
84 | },
85 | mockError: nil,
86 | expectedError: false,
87 | expectedStatus: http.StatusOK,
88 | expectedRespBody: "ok",
89 | },
90 | }
91 |
92 | for _, tt := range tests {
93 | t.Run(tt.name, func(t *testing.T) {
94 | mockAPI := new(MockPortainerAPI)
95 | proxyOpts := client.ProxyRequestOptions{
96 | Method: tt.opts.Method,
97 | APIPath: tt.opts.Path,
98 | QueryParams: tt.opts.QueryParams,
99 | Headers: tt.opts.Headers,
100 | Body: tt.opts.Body,
101 | }
102 | mockAPI.On("ProxyKubernetesRequest", tt.opts.EnvironmentID, proxyOpts).Return(tt.mockResponse, tt.mockError)
103 |
104 | portainerClient := &PortainerClient{cli: mockAPI}
105 |
106 | resp, err := portainerClient.ProxyKubernetesRequest(tt.opts)
107 |
108 | if tt.expectedError {
109 | assert.Error(t, err)
110 | assert.EqualError(t, err, tt.mockError.Error())
111 | assert.Nil(t, resp)
112 | } else {
113 | assert.NoError(t, err)
114 | assert.NotNil(t, resp)
115 | assert.Equal(t, tt.expectedStatus, resp.StatusCode)
116 |
117 | // Read and verify the response body
118 | if assert.NotNil(t, resp.Body) { // Ensure body is not nil before reading
119 | defer resp.Body.Close()
120 | bodyBytes, readErr := io.ReadAll(resp.Body)
121 | assert.NoError(t, readErr)
122 | assert.Equal(t, tt.expectedRespBody, string(bodyBytes))
123 | } else if tt.expectedRespBody != "" {
124 | assert.Fail(t, "Expected a response body but got nil")
125 | }
126 | }
127 |
128 | mockAPI.AssertExpectations(t)
129 | })
130 | }
131 | }
132 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/access_group.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
7 | "github.com/portainer/portainer-mcp/pkg/portainer/utils"
8 | )
9 |
10 | // GetAccessGroups retrieves all access groups from the Portainer server.
11 | // Access groups are the equivalent of Endpoint Groups in Portainer.
12 | //
13 | // Returns:
14 | // - A slice of AccessGroup objects
15 | // - An error if the operation fails
16 | func (c *PortainerClient) GetAccessGroups() ([]models.AccessGroup, error) {
17 | groups, err := c.cli.ListEndpointGroups()
18 | if err != nil {
19 | return nil, err
20 | }
21 |
22 | endpoints, err := c.cli.ListEndpoints()
23 | if err != nil {
24 | return nil, err
25 | }
26 |
27 | accessGroups := make([]models.AccessGroup, len(groups))
28 | for i, group := range groups {
29 | accessGroups[i] = models.ConvertEndpointGroupToAccessGroup(group, endpoints)
30 | }
31 |
32 | return accessGroups, nil
33 | }
34 |
35 | // CreateAccessGroup creates a new access group in Portainer.
36 | //
37 | // Parameters:
38 | // - name: The name of the access group
39 | // - environmentIds: The IDs of the environments that are part of the access group
40 | //
41 | // Returns:
42 | // - An error if the operation fails
43 | func (c *PortainerClient) CreateAccessGroup(name string, environmentIds []int) (int, error) {
44 | groupID, err := c.cli.CreateEndpointGroup(name, utils.IntToInt64Slice(environmentIds))
45 | if err != nil {
46 | return 0, fmt.Errorf("failed to create access group: %w", err)
47 | }
48 |
49 | return int(groupID), nil
50 | }
51 |
52 | // UpdateAccessGroupName updates the name of an existing access group in Portainer.
53 | //
54 | // Parameters:
55 | // - id: The ID of the access group
56 | // - name: The new name for the access group
57 | //
58 | // Returns:
59 | // - An error if the operation fails
60 | func (c *PortainerClient) UpdateAccessGroupName(id int, name string) error {
61 | err := c.cli.UpdateEndpointGroup(int64(id), &name, nil, nil)
62 | if err != nil {
63 | return fmt.Errorf("failed to update access group name: %w", err)
64 | }
65 | return nil
66 | }
67 |
68 | // UpdateAccessGroupUserAccesses updates the user access policies of an existing access group in Portainer.
69 | //
70 | // Parameters:
71 | // - id: The ID of the access group
72 | // - userAccesses: Map of user IDs to their access level
73 | //
74 | // Valid access levels are:
75 | // - environment_administrator
76 | // - helpdesk_user
77 | // - standard_user
78 | // - readonly_user
79 | // - operator_user
80 | //
81 | // Returns:
82 | // - An error if the operation fails
83 | func (c *PortainerClient) UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error {
84 | uac := utils.IntToInt64Map(userAccesses)
85 | err := c.cli.UpdateEndpointGroup(int64(id), nil, &uac, nil)
86 | if err != nil {
87 | return fmt.Errorf("failed to update access group user accesses: %w", err)
88 | }
89 | return nil
90 | }
91 |
92 | // UpdateAccessGroupTeamAccesses updates the team access policies of an existing access group in Portainer.
93 | //
94 | // Parameters:
95 | // - id: The ID of the access group
96 | // - teamAccesses: Map of team IDs to their access level
97 | //
98 | // Valid access levels are:
99 | // - environment_administrator
100 | // - helpdesk_user
101 | // - standard_user
102 | // - readonly_user
103 | // - operator_user
104 | //
105 | // Returns:
106 | // - An error if the operation fails
107 | func (c *PortainerClient) UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error {
108 | tac := utils.IntToInt64Map(teamAccesses)
109 | err := c.cli.UpdateEndpointGroup(int64(id), nil, nil, &tac)
110 | if err != nil {
111 | return fmt.Errorf("failed to update access group team accesses: %w", err)
112 | }
113 | return nil
114 | }
115 |
116 | // AddEnvironmentToAccessGroup adds an environment to an access group
117 | //
118 | // Parameters:
119 | // - id: The ID of the access group
120 | // - environmentId: The ID of the environment to add to the access group
121 | //
122 | // Returns:
123 | // - An error if the operation fails
124 | func (c *PortainerClient) AddEnvironmentToAccessGroup(id int, environmentId int) error {
125 | return c.cli.AddEnvironmentToEndpointGroup(int64(id), int64(environmentId))
126 | }
127 |
128 | // RemoveEnvironmentFromAccessGroup removes an environment from an access group
129 | //
130 | // Parameters:
131 | // - id: The ID of the access group
132 | // - environmentId: The ID of the environment to remove from the access group
133 | //
134 | // Returns:
135 | // - An error if the operation fails
136 | func (c *PortainerClient) RemoveEnvironmentFromAccessGroup(id int, environmentId int) error {
137 | return c.cli.RemoveEnvironmentFromEndpointGroup(int64(id), int64(environmentId))
138 | }
139 |
```
--------------------------------------------------------------------------------
/internal/mcp/tag_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "testing"
8 |
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | func TestHandleGetEnvironmentTags(t *testing.T) {
15 | tests := []struct {
16 | name string
17 | mockTags []models.EnvironmentTag
18 | mockError error
19 | expectError bool
20 | expectedJSON string
21 | }{
22 | {
23 | name: "successful tags retrieval",
24 | mockTags: []models.EnvironmentTag{
25 | {ID: 1, Name: "tag1"},
26 | {ID: 2, Name: "tag2"},
27 | },
28 | mockError: nil,
29 | expectError: false,
30 | },
31 | {
32 | name: "api error",
33 | mockTags: nil,
34 | mockError: fmt.Errorf("api error"),
35 | expectError: true,
36 | },
37 | }
38 |
39 | for _, tt := range tests {
40 | t.Run(tt.name, func(t *testing.T) {
41 | // Create mock client
42 | mockClient := &MockPortainerClient{}
43 | mockClient.On("GetEnvironmentTags").Return(tt.mockTags, tt.mockError)
44 |
45 | // Create server with mock client
46 | server := &PortainerMCPServer{
47 | cli: mockClient,
48 | }
49 |
50 | // Call handler
51 | handler := server.HandleGetEnvironmentTags()
52 | result, err := handler(context.Background(), mcp.CallToolRequest{})
53 |
54 | // Verify results
55 | if tt.expectError {
56 | assert.NoError(t, err)
57 | assert.NotNil(t, result)
58 | assert.True(t, result.IsError, "result.IsError should be true for API errors")
59 | assert.Len(t, result.Content, 1)
60 | textContent, ok := result.Content[0].(mcp.TextContent)
61 | assert.True(t, ok, "Result content should be mcp.TextContent")
62 | if tt.mockError != nil {
63 | assert.Contains(t, textContent.Text, tt.mockError.Error())
64 | }
65 | } else {
66 | assert.NoError(t, err)
67 |
68 | // Verify JSON response
69 | assert.Len(t, result.Content, 1)
70 | textContent, ok := result.Content[0].(mcp.TextContent)
71 | assert.True(t, ok)
72 |
73 | var tags []models.EnvironmentTag
74 | err = json.Unmarshal([]byte(textContent.Text), &tags)
75 | assert.NoError(t, err)
76 | assert.Equal(t, tt.mockTags, tags)
77 | }
78 |
79 | // Verify mock expectations
80 | mockClient.AssertExpectations(t)
81 | })
82 | }
83 | }
84 |
85 | func TestHandleCreateEnvironmentTag(t *testing.T) {
86 | tests := []struct {
87 | name string
88 | inputName string
89 | mockID int
90 | mockError error
91 | expectError bool
92 | }{
93 | {
94 | name: "successful tag creation",
95 | inputName: "test-tag",
96 | mockID: 123,
97 | mockError: nil,
98 | expectError: false,
99 | },
100 | {
101 | name: "api error",
102 | inputName: "test-tag",
103 | mockID: 0,
104 | mockError: fmt.Errorf("api error"),
105 | expectError: true,
106 | },
107 | {
108 | name: "missing name parameter",
109 | inputName: "",
110 | mockID: 0,
111 | mockError: nil,
112 | expectError: true,
113 | },
114 | }
115 |
116 | for _, tt := range tests {
117 | t.Run(tt.name, func(t *testing.T) {
118 | // Create mock client
119 | mockClient := &MockPortainerClient{}
120 | if tt.inputName != "" {
121 | mockClient.On("CreateEnvironmentTag", tt.inputName).Return(tt.mockID, tt.mockError)
122 | }
123 |
124 | // Create server with mock client
125 | server := &PortainerMCPServer{
126 | cli: mockClient,
127 | }
128 |
129 | // Create request with parameters
130 | request := CreateMCPRequest(map[string]any{})
131 | if tt.inputName != "" {
132 | request.Params.Arguments = map[string]any{
133 | "name": tt.inputName,
134 | }
135 | }
136 |
137 | // Call handler
138 | handler := server.HandleCreateEnvironmentTag()
139 | result, err := handler(context.Background(), request)
140 |
141 | // Verify results
142 | if tt.expectError {
143 | assert.NoError(t, err)
144 | assert.NotNil(t, result)
145 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
146 | assert.Len(t, result.Content, 1)
147 | textContent, ok := result.Content[0].(mcp.TextContent)
148 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
149 | if tt.mockError != nil {
150 | assert.Contains(t, textContent.Text, tt.mockError.Error())
151 | } else {
152 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
153 | if tt.inputName == "" {
154 | assert.Contains(t, textContent.Text, "name")
155 | }
156 | }
157 | } else {
158 | assert.NoError(t, err)
159 | assert.Len(t, result.Content, 1)
160 | textContent, ok := result.Content[0].(mcp.TextContent)
161 | assert.True(t, ok)
162 | assert.Contains(t, textContent.Text,
163 | fmt.Sprintf("ID: %d", tt.mockID))
164 | }
165 |
166 | // Verify mock expectations
167 | mockClient.AssertExpectations(t)
168 | })
169 | }
170 | }
171 |
```
--------------------------------------------------------------------------------
/pkg/portainer/models/settings_test.go:
--------------------------------------------------------------------------------
```go
1 | package models
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/portainer/client-api-go/v2/pkg/models"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestConvertAuthenticationMethod(t *testing.T) {
11 | tests := []struct {
12 | name string
13 | methodID int64
14 | expectedMethod string
15 | }{
16 | {
17 | name: "Internal authentication",
18 | methodID: 1,
19 | expectedMethod: AuthenticationMethodInternal,
20 | },
21 | {
22 | name: "LDAP authentication",
23 | methodID: 2,
24 | expectedMethod: AuthenticationMethodLDAP,
25 | },
26 | {
27 | name: "OAuth authentication",
28 | methodID: 3,
29 | expectedMethod: AuthenticationMethodOAuth,
30 | },
31 | {
32 | name: "Unknown authentication (0)",
33 | methodID: 0,
34 | expectedMethod: AuthenticationMethodUnknown,
35 | },
36 | {
37 | name: "Unknown authentication (negative)",
38 | methodID: -1,
39 | expectedMethod: AuthenticationMethodUnknown,
40 | },
41 | {
42 | name: "Unknown authentication (large value)",
43 | methodID: 999,
44 | expectedMethod: AuthenticationMethodUnknown,
45 | },
46 | }
47 |
48 | for _, tt := range tests {
49 | t.Run(tt.name, func(t *testing.T) {
50 | result := convertAuthenticationMethod(tt.methodID)
51 | assert.Equal(t, tt.expectedMethod, result)
52 | })
53 | }
54 | }
55 |
56 | func TestConvertSettingsToPortainerSettings(t *testing.T) {
57 | tests := []struct {
58 | name string
59 | input *models.PortainereeSettings
60 | expectedOutput PortainerSettings
61 | shouldPanic bool
62 | }{
63 | {
64 | name: "Complete settings conversion",
65 | input: &models.PortainereeSettings{
66 | AuthenticationMethod: 1,
67 | EnableEdgeComputeFeatures: true,
68 | Edge: &models.PortainereeEdge{
69 | TunnelServerAddress: "https://edge.example.com",
70 | },
71 | },
72 | expectedOutput: PortainerSettings{
73 | Authentication: struct {
74 | Method string `json:"method"`
75 | }{
76 | Method: AuthenticationMethodInternal,
77 | },
78 | Edge: struct {
79 | Enabled bool `json:"enabled"`
80 | ServerURL string `json:"server_url"`
81 | }{
82 | Enabled: true,
83 | ServerURL: "https://edge.example.com",
84 | },
85 | },
86 | },
87 | {
88 | name: "Settings with LDAP authentication",
89 | input: &models.PortainereeSettings{
90 | AuthenticationMethod: 2,
91 | EnableEdgeComputeFeatures: false,
92 | Edge: &models.PortainereeEdge{
93 | TunnelServerAddress: "",
94 | },
95 | },
96 | expectedOutput: PortainerSettings{
97 | Authentication: struct {
98 | Method string `json:"method"`
99 | }{
100 | Method: AuthenticationMethodLDAP,
101 | },
102 | Edge: struct {
103 | Enabled bool `json:"enabled"`
104 | ServerURL string `json:"server_url"`
105 | }{
106 | Enabled: false,
107 | ServerURL: "",
108 | },
109 | },
110 | },
111 | {
112 | name: "Settings with OAuth authentication",
113 | input: &models.PortainereeSettings{
114 | AuthenticationMethod: 3,
115 | EnableEdgeComputeFeatures: true,
116 | Edge: &models.PortainereeEdge{
117 | TunnelServerAddress: "https://tunnel.portainer.io",
118 | },
119 | },
120 | expectedOutput: PortainerSettings{
121 | Authentication: struct {
122 | Method string `json:"method"`
123 | }{
124 | Method: AuthenticationMethodOAuth,
125 | },
126 | Edge: struct {
127 | Enabled bool `json:"enabled"`
128 | ServerURL string `json:"server_url"`
129 | }{
130 | Enabled: true,
131 | ServerURL: "https://tunnel.portainer.io",
132 | },
133 | },
134 | },
135 | {
136 | name: "Settings with unknown authentication",
137 | input: &models.PortainereeSettings{
138 | AuthenticationMethod: 99,
139 | EnableEdgeComputeFeatures: false,
140 | Edge: &models.PortainereeEdge{
141 | TunnelServerAddress: "",
142 | },
143 | },
144 | expectedOutput: PortainerSettings{
145 | Authentication: struct {
146 | Method string `json:"method"`
147 | }{
148 | Method: AuthenticationMethodUnknown,
149 | },
150 | Edge: struct {
151 | Enabled bool `json:"enabled"`
152 | ServerURL string `json:"server_url"`
153 | }{
154 | Enabled: false,
155 | ServerURL: "",
156 | },
157 | },
158 | },
159 | {
160 | name: "Nil input",
161 | input: nil,
162 | shouldPanic: true,
163 | },
164 | }
165 |
166 | for _, tt := range tests {
167 | t.Run(tt.name, func(t *testing.T) {
168 | if tt.shouldPanic {
169 | assert.Panics(t, func() {
170 | ConvertSettingsToPortainerSettings(tt.input)
171 | })
172 | return
173 | }
174 |
175 | result := ConvertSettingsToPortainerSettings(tt.input)
176 | assert.Equal(t, tt.expectedOutput.Authentication.Method, result.Authentication.Method)
177 | assert.Equal(t, tt.expectedOutput.Edge.Enabled, result.Edge.Enabled)
178 | assert.Equal(t, tt.expectedOutput.Edge.ServerURL, result.Edge.ServerURL)
179 | })
180 | }
181 | }
182 |
```
--------------------------------------------------------------------------------
/internal/mcp/group.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/portainer/portainer-mcp/pkg/toolgen"
11 | )
12 |
13 | func (s *PortainerMCPServer) AddEnvironmentGroupFeatures() {
14 | s.addToolIfExists(ToolListEnvironmentGroups, s.HandleGetEnvironmentGroups())
15 |
16 | if !s.readOnly {
17 | s.addToolIfExists(ToolCreateEnvironmentGroup, s.HandleCreateEnvironmentGroup())
18 | s.addToolIfExists(ToolUpdateEnvironmentGroupName, s.HandleUpdateEnvironmentGroupName())
19 | s.addToolIfExists(ToolUpdateEnvironmentGroupEnvironments, s.HandleUpdateEnvironmentGroupEnvironments())
20 | s.addToolIfExists(ToolUpdateEnvironmentGroupTags, s.HandleUpdateEnvironmentGroupTags())
21 | }
22 | }
23 |
24 | func (s *PortainerMCPServer) HandleGetEnvironmentGroups() server.ToolHandlerFunc {
25 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
26 | edgeGroups, err := s.cli.GetEnvironmentGroups()
27 | if err != nil {
28 | return mcp.NewToolResultErrorFromErr("failed to get environment groups", err), nil
29 | }
30 |
31 | data, err := json.Marshal(edgeGroups)
32 | if err != nil {
33 | return mcp.NewToolResultErrorFromErr("failed to marshal environment groups", err), nil
34 | }
35 |
36 | return mcp.NewToolResultText(string(data)), nil
37 | }
38 | }
39 |
40 | func (s *PortainerMCPServer) HandleCreateEnvironmentGroup() server.ToolHandlerFunc {
41 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
42 | parser := toolgen.NewParameterParser(request)
43 |
44 | name, err := parser.GetString("name", true)
45 | if err != nil {
46 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
47 | }
48 |
49 | environmentIds, err := parser.GetArrayOfIntegers("environmentIds", true)
50 | if err != nil {
51 | return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
52 | }
53 |
54 | id, err := s.cli.CreateEnvironmentGroup(name, environmentIds)
55 | if err != nil {
56 | return mcp.NewToolResultErrorFromErr("failed to create environment group", err), nil
57 | }
58 |
59 | return mcp.NewToolResultText(fmt.Sprintf("Environment group created successfully with ID: %d", id)), nil
60 | }
61 | }
62 |
63 | func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupName() server.ToolHandlerFunc {
64 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
65 | parser := toolgen.NewParameterParser(request)
66 |
67 | id, err := parser.GetInt("id", true)
68 | if err != nil {
69 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
70 | }
71 |
72 | name, err := parser.GetString("name", true)
73 | if err != nil {
74 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
75 | }
76 |
77 | err = s.cli.UpdateEnvironmentGroupName(id, name)
78 | if err != nil {
79 | return mcp.NewToolResultErrorFromErr("failed to update environment group name", err), nil
80 | }
81 |
82 | return mcp.NewToolResultText("Environment group name updated successfully"), nil
83 | }
84 | }
85 |
86 | func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupEnvironments() server.ToolHandlerFunc {
87 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
88 | parser := toolgen.NewParameterParser(request)
89 |
90 | id, err := parser.GetInt("id", true)
91 | if err != nil {
92 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
93 | }
94 |
95 | environmentIds, err := parser.GetArrayOfIntegers("environmentIds", true)
96 | if err != nil {
97 | return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
98 | }
99 |
100 | err = s.cli.UpdateEnvironmentGroupEnvironments(id, environmentIds)
101 | if err != nil {
102 | return mcp.NewToolResultErrorFromErr("failed to update environment group environments", err), nil
103 | }
104 |
105 | return mcp.NewToolResultText("Environment group environments updated successfully"), nil
106 | }
107 | }
108 |
109 | func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupTags() server.ToolHandlerFunc {
110 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
111 | parser := toolgen.NewParameterParser(request)
112 |
113 | id, err := parser.GetInt("id", true)
114 | if err != nil {
115 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
116 | }
117 |
118 | tagIds, err := parser.GetArrayOfIntegers("tagIds", true)
119 | if err != nil {
120 | return mcp.NewToolResultErrorFromErr("invalid tagIds parameter", err), nil
121 | }
122 |
123 | err = s.cli.UpdateEnvironmentGroupTags(id, tagIds)
124 | if err != nil {
125 | return mcp.NewToolResultErrorFromErr("failed to update environment group tags", err), nil
126 | }
127 |
128 | return mcp.NewToolResultText("Environment group tags updated successfully"), nil
129 | }
130 | }
131 |
```
--------------------------------------------------------------------------------
/internal/mcp/utils_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestParseAccessMap(t *testing.T) {
9 | tests := []struct {
10 | name string
11 | entries []any
12 | want map[int]string
13 | wantErr bool
14 | }{
15 | {
16 | name: "Valid single entry",
17 | entries: []any{
18 | map[string]any{
19 | "id": float64(1),
20 | "access": AccessLevelEnvironmentAdmin,
21 | },
22 | },
23 | want: map[int]string{
24 | 1: AccessLevelEnvironmentAdmin,
25 | },
26 | wantErr: false,
27 | },
28 | {
29 | name: "Valid multiple entries",
30 | entries: []any{
31 | map[string]any{
32 | "id": float64(1),
33 | "access": AccessLevelEnvironmentAdmin,
34 | },
35 | map[string]any{
36 | "id": float64(2),
37 | "access": AccessLevelReadonlyUser,
38 | },
39 | },
40 | want: map[int]string{
41 | 1: AccessLevelEnvironmentAdmin,
42 | 2: AccessLevelReadonlyUser,
43 | },
44 | wantErr: false,
45 | },
46 | {
47 | name: "Invalid entry type",
48 | entries: []any{
49 | "not a map",
50 | },
51 | want: nil,
52 | wantErr: true,
53 | },
54 | {
55 | name: "Invalid ID type",
56 | entries: []any{
57 | map[string]any{
58 | "id": "string-id",
59 | "access": AccessLevelEnvironmentAdmin,
60 | },
61 | },
62 | want: nil,
63 | wantErr: true,
64 | },
65 | {
66 | name: "Invalid access type",
67 | entries: []any{
68 | map[string]any{
69 | "id": float64(1),
70 | "access": 123,
71 | },
72 | },
73 | want: nil,
74 | wantErr: true,
75 | },
76 | {
77 | name: "Invalid access level",
78 | entries: []any{
79 | map[string]any{
80 | "id": float64(1),
81 | "access": "invalid_access_level",
82 | },
83 | },
84 | want: nil,
85 | wantErr: true,
86 | },
87 | {
88 | name: "Empty entries",
89 | entries: []any{},
90 | want: map[int]string{},
91 | wantErr: false,
92 | },
93 | {
94 | name: "Missing ID field",
95 | entries: []any{
96 | map[string]any{
97 | "access": AccessLevelEnvironmentAdmin,
98 | },
99 | },
100 | want: nil,
101 | wantErr: true,
102 | },
103 | {
104 | name: "Missing access field",
105 | entries: []any{
106 | map[string]any{
107 | "id": float64(1),
108 | },
109 | },
110 | want: nil,
111 | wantErr: true,
112 | },
113 | }
114 |
115 | for _, tt := range tests {
116 | t.Run(tt.name, func(t *testing.T) {
117 | got, err := parseAccessMap(tt.entries)
118 | if (err != nil) != tt.wantErr {
119 | t.Errorf("parseAccessMap() error = %v, wantErr %v", err, tt.wantErr)
120 | return
121 | }
122 | if !reflect.DeepEqual(got, tt.want) {
123 | t.Errorf("parseAccessMap() = %v, want %v", got, tt.want)
124 | }
125 | })
126 | }
127 | }
128 |
129 | func TestIsValidHTTPMethod(t *testing.T) {
130 | tests := []struct {
131 | name string
132 | method string
133 | expect bool
134 | }{
135 | {"Valid GET", "GET", true},
136 | {"Valid POST", "POST", true},
137 | {"Valid PUT", "PUT", true},
138 | {"Valid DELETE", "DELETE", true},
139 | {"Valid HEAD", "HEAD", true},
140 | {"Invalid lowercase get", "get", false},
141 | {"Invalid PATCH", "PATCH", false},
142 | {"Invalid OPTIONS", "OPTIONS", false},
143 | {"Invalid Empty", "", false},
144 | {"Invalid Random", "RANDOM", false},
145 | }
146 |
147 | for _, tt := range tests {
148 | t.Run(tt.name, func(t *testing.T) {
149 | got := isValidHTTPMethod(tt.method)
150 | if got != tt.expect {
151 | t.Errorf("isValidHTTPMethod(%q) = %v, want %v", tt.method, got, tt.expect)
152 | }
153 | })
154 | }
155 | }
156 |
157 | func TestParseKeyValueMap(t *testing.T) {
158 | tests := []struct {
159 | name string
160 | items []any
161 | want map[string]string
162 | wantErr bool
163 | }{
164 | {
165 | name: "Valid single entry",
166 | items: []any{
167 | map[string]any{"key": "k1", "value": "v1"},
168 | },
169 | want: map[string]string{
170 | "k1": "v1",
171 | },
172 | wantErr: false,
173 | },
174 | {
175 | name: "Valid multiple entries",
176 | items: []any{
177 | map[string]any{"key": "k1", "value": "v1"},
178 | map[string]any{"key": "k2", "value": "v2"},
179 | },
180 | want: map[string]string{
181 | "k1": "v1",
182 | "k2": "v2",
183 | },
184 | wantErr: false,
185 | },
186 | {
187 | name: "Empty items",
188 | items: []any{},
189 | want: map[string]string{},
190 | wantErr: false,
191 | },
192 | {
193 | name: "Invalid item type",
194 | items: []any{
195 | "not a map",
196 | },
197 | want: nil,
198 | wantErr: true,
199 | },
200 | {
201 | name: "Invalid key type",
202 | items: []any{
203 | map[string]any{"key": 123, "value": "v1"},
204 | },
205 | want: nil,
206 | wantErr: true,
207 | },
208 | {
209 | name: "Invalid value type",
210 | items: []any{
211 | map[string]any{"key": "k1", "value": 123},
212 | },
213 | want: nil,
214 | wantErr: true,
215 | },
216 | {
217 | name: "Missing key field",
218 | items: []any{
219 | map[string]any{"value": "v1"},
220 | },
221 | want: nil,
222 | wantErr: true,
223 | },
224 | {
225 | name: "Missing value field",
226 | items: []any{
227 | map[string]any{"key": "k1"},
228 | },
229 | want: nil,
230 | wantErr: true,
231 | },
232 | }
233 |
234 | for _, tt := range tests {
235 | t.Run(tt.name, func(t *testing.T) {
236 | got, err := parseKeyValueMap(tt.items)
237 | if (err != nil) != tt.wantErr {
238 | t.Errorf("parseKeyValueMap() error = %v, wantErr %v", err, tt.wantErr)
239 | return
240 | }
241 | if !reflect.DeepEqual(got, tt.want) {
242 | t.Errorf("parseKeyValueMap() = %v, want %v", got, tt.want)
243 | }
244 | })
245 | }
246 | }
247 |
```
--------------------------------------------------------------------------------
/pkg/toolgen/yaml.go:
--------------------------------------------------------------------------------
```go
1 | package toolgen
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "golang.org/x/mod/semver"
10 | "gopkg.in/yaml.v3"
11 | )
12 |
13 | // ToolsConfig represents the entire YAML configuration
14 | type ToolsConfig struct {
15 | Version string `yaml:"version"`
16 | Tools []ToolDefinition `yaml:"tools"`
17 | }
18 |
19 | // ToolDefinition represents a single tool in the YAML config
20 | type ToolDefinition struct {
21 | Name string `yaml:"name"`
22 | Description string `yaml:"description"`
23 | Parameters []ParameterDefinition `yaml:"parameters"`
24 | Annotations Annotations `yaml:"annotations"`
25 | }
26 |
27 | // ParameterDefinition represents a tool parameter in the YAML config
28 | type ParameterDefinition struct {
29 | Name string `yaml:"name"`
30 | Type string `yaml:"type"`
31 | Required bool `yaml:"required"`
32 | Enum []string `yaml:"enum,omitempty"`
33 | Description string `yaml:"description"`
34 | Items map[string]any `yaml:"items,omitempty"`
35 | }
36 |
37 | // Annotations represents a tool annotations in the YAML config
38 | type Annotations struct {
39 | Title string `yaml:"title"`
40 | ReadOnlyHint bool `yaml:"readOnlyHint"`
41 | DestructiveHint bool `yaml:"destructiveHint"`
42 | IdempotentHint bool `yaml:"idempotentHint"`
43 | OpenWorldHint bool `yaml:"openWorldHint"`
44 | }
45 |
46 | // LoadToolsFromYAML loads tool definitions from a YAML file
47 | // It returns the tools and the version of the tools.yaml file
48 | func LoadToolsFromYAML(filePath string, minimumVersion string) (map[string]mcp.Tool, error) {
49 | data, err := os.ReadFile(filePath)
50 | if err != nil {
51 | return nil, err
52 | }
53 |
54 | var config ToolsConfig
55 | if err := yaml.Unmarshal(data, &config); err != nil {
56 | return nil, err
57 | }
58 |
59 | if config.Version == "" {
60 | return nil, fmt.Errorf("missing version in tools.yaml")
61 | }
62 |
63 | if !semver.IsValid(config.Version) {
64 | return nil, fmt.Errorf("invalid version in tools.yaml: %s", config.Version)
65 | }
66 |
67 | if semver.Compare(config.Version, minimumVersion) < 0 {
68 | return nil, fmt.Errorf("tools.yaml version %s is below the minimum required version %s", config.Version, minimumVersion)
69 | }
70 |
71 | return convertToolDefinitions(config.Tools), nil
72 | }
73 |
74 | // convertToolDefinitions converts YAML tool definitions to mcp.Tool objects
75 | func convertToolDefinitions(defs []ToolDefinition) map[string]mcp.Tool {
76 | tools := make(map[string]mcp.Tool, len(defs))
77 |
78 | for _, def := range defs {
79 | tool, err := convertToolDefinition(def)
80 | if err != nil {
81 | log.Printf("skipping invalid tool definition %s: %s", def.Name, err)
82 | continue
83 | }
84 |
85 | tools[def.Name] = tool
86 | }
87 |
88 | return tools
89 | }
90 |
91 | // convertToolDefinition converts a single YAML tool definition to an mcp.Tool
92 | func convertToolDefinition(def ToolDefinition) (mcp.Tool, error) {
93 | if def.Name == "" {
94 | return mcp.Tool{}, fmt.Errorf("tool name is required")
95 | }
96 |
97 | if def.Description == "" {
98 | return mcp.Tool{}, fmt.Errorf("tool description is required for tool '%s'", def.Name)
99 | }
100 |
101 | var zeroAnnotations Annotations
102 | if def.Annotations == zeroAnnotations {
103 | return mcp.Tool{}, fmt.Errorf("annotations block is required for tool '%s'", def.Name)
104 | }
105 |
106 | options := []mcp.ToolOption{
107 | mcp.WithDescription(def.Description),
108 | }
109 |
110 | for _, param := range def.Parameters {
111 | options = append(options, convertParameter(param))
112 | }
113 |
114 | options = append(options, convertAnnotation(def.Annotations))
115 |
116 | return mcp.NewTool(def.Name, options...), nil
117 | }
118 |
119 | // convertAnnotation converts a YAML annotation definition to an mcp option
120 | func convertAnnotation(annotation Annotations) mcp.ToolOption {
121 | return mcp.WithToolAnnotation(mcp.ToolAnnotation{
122 | Title: annotation.Title,
123 | ReadOnlyHint: &annotation.ReadOnlyHint,
124 | DestructiveHint: &annotation.DestructiveHint,
125 | IdempotentHint: &annotation.IdempotentHint,
126 | OpenWorldHint: &annotation.OpenWorldHint,
127 | })
128 | }
129 |
130 | // convertParameter converts a YAML parameter definition to an mcp option
131 | func convertParameter(param ParameterDefinition) mcp.ToolOption {
132 | var options []mcp.PropertyOption
133 |
134 | options = append(options, mcp.Description(param.Description))
135 |
136 | if param.Required {
137 | options = append(options, mcp.Required())
138 | }
139 |
140 | if param.Enum != nil {
141 | options = append(options, mcp.Enum(param.Enum...))
142 | }
143 |
144 | if len(param.Items) > 0 {
145 | options = append(options, mcp.Items(param.Items))
146 | }
147 |
148 | switch param.Type {
149 | case "string":
150 | return mcp.WithString(param.Name, options...)
151 | case "number":
152 | return mcp.WithNumber(param.Name, options...)
153 | case "boolean":
154 | return mcp.WithBoolean(param.Name, options...)
155 | case "array":
156 | return mcp.WithArray(param.Name, options...)
157 | case "object":
158 | return mcp.WithObject(param.Name, options...)
159 | default:
160 | // Default to string if type is unknown
161 | return mcp.WithString(param.Name, options...)
162 | }
163 | }
164 |
```
--------------------------------------------------------------------------------
/tests/integration/server_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "testing"
7 |
8 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/portainer/portainer-mcp/internal/mcp"
10 | "github.com/portainer/portainer-mcp/tests/integration/containers"
11 | "github.com/stretchr/testify/assert"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | const (
16 | toolsPath = "../../internal/tooldef/tools.yaml"
17 | unsupportedImage = "portainer/portainer-ee:2.29.1" // Older version than SupportedPortainerVersion
18 | )
19 |
20 | // TestServerInitialization verifies that the Portainer MCP server
21 | // can be successfully initialized with a real Portainer instance.
22 | func TestServerInitialization(t *testing.T) {
23 | // Start a Portainer container
24 | ctx := context.Background()
25 |
26 | portainer, err := containers.NewPortainerContainer(ctx)
27 | require.NoError(t, err, "Failed to start Portainer container")
28 |
29 | // Ensure container is terminated at the end of the test
30 | defer func() {
31 | if err := portainer.Terminate(ctx); err != nil {
32 | t.Logf("Failed to terminate container: %v", err)
33 | }
34 | }()
35 |
36 | // Get the host and port for the Portainer API
37 | host, port := portainer.GetHostAndPort()
38 | serverURL := fmt.Sprintf("%s:%s", host, port)
39 | apiToken := portainer.GetAPIToken()
40 |
41 | // Create the MCP server - this is the main test objective
42 | mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath)
43 |
44 | // Assert the server was created successfully
45 | require.NoError(t, err, "Failed to create MCP server")
46 | require.NotNil(t, mcpServer, "MCP server should not be nil")
47 | }
48 |
49 | // TestServerInitializationUnsupportedVersion verifies that the Portainer MCP server
50 | // correctly rejects initialization with an unsupported Portainer version.
51 | func TestServerInitializationUnsupportedVersion(t *testing.T) {
52 | // Start a Portainer container with unsupported version
53 | ctx := context.Background()
54 |
55 | portainer, err := containers.NewPortainerContainer(ctx, containers.WithImage(unsupportedImage))
56 | require.NoError(t, err, "Failed to start unsupported Portainer container")
57 |
58 | // Ensure container is terminated at the end of the test
59 | defer func() {
60 | if err := portainer.Terminate(ctx); err != nil {
61 | t.Logf("Failed to terminate container: %v", err)
62 | }
63 | }()
64 |
65 | // Get the host and port for the Portainer API
66 | host, port := portainer.GetHostAndPort()
67 | serverURL := fmt.Sprintf("%s:%s", host, port)
68 | apiToken := portainer.GetAPIToken()
69 |
70 | // Try to create the MCP server - should fail with version error
71 | mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath)
72 |
73 | // Assert the server creation failed with correct error
74 | assert.Error(t, err, "Server creation should fail with unsupported version")
75 | assert.Contains(t, err.Error(), "unsupported Portainer server version", "Error should indicate version mismatch")
76 | assert.Nil(t, mcpServer, "Server should be nil when version check fails")
77 | }
78 |
79 | // TestServerInitializationDisabledVersionCheck verifies that the Portainer MCP server
80 | // can successfully connect to unsupported Portainer versions when version check is disabled.
81 | func TestServerInitializationDisabledVersionCheck(t *testing.T) {
82 | // Start a Portainer container with unsupported version
83 | ctx := context.Background()
84 |
85 | portainer, err := containers.NewPortainerContainer(ctx, containers.WithImage(unsupportedImage))
86 | require.NoError(t, err, "Failed to start unsupported Portainer container")
87 |
88 | // Ensure container is terminated at the end of the test
89 | defer func() {
90 | if err := portainer.Terminate(ctx); err != nil {
91 | t.Logf("Failed to terminate container: %v", err)
92 | }
93 | }()
94 |
95 | // Get the host and port for the Portainer API
96 | host, port := portainer.GetHostAndPort()
97 | serverURL := fmt.Sprintf("%s:%s", host, port)
98 | apiToken := portainer.GetAPIToken()
99 |
100 | // Create the MCP server with disabled version check - should succeed despite unsupported version
101 | mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath, mcp.WithDisableVersionCheck(true))
102 |
103 | // Assert the server was created successfully
104 | require.NoError(t, err, "Failed to create MCP server with disabled version check")
105 | require.NotNil(t, mcpServer, "MCP server should not be nil when version check is disabled")
106 |
107 | // Verify basic functionality by testing settings retrieval
108 | handler := mcpServer.HandleGetSettings()
109 | request := mcp.CreateMCPRequest(nil) // GetSettings doesn't require parameters
110 |
111 | result, err := handler(ctx, request)
112 | require.NoError(t, err, "Failed to get settings via MCP handler with disabled version check")
113 | require.NotNil(t, result, "Settings result should not be nil")
114 | require.Len(t, result.Content, 1, "Expected exactly one content block in settings result")
115 |
116 | // Verify the response contains valid content
117 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
118 | require.True(t, ok, "Expected text content in settings MCP response")
119 | assert.NotEmpty(t, textContent.Text, "Settings response should not be empty")
120 | }
121 |
```
--------------------------------------------------------------------------------
/internal/mcp/kubernetes.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "strings"
8 |
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/mark3labs/mcp-go/server"
11 | "github.com/portainer/portainer-mcp/internal/k8sutil"
12 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
13 | "github.com/portainer/portainer-mcp/pkg/toolgen"
14 | )
15 |
16 | func (s *PortainerMCPServer) AddKubernetesProxyFeatures() {
17 | s.addToolIfExists(ToolKubernetesProxyStripped, s.HandleKubernetesProxyStripped())
18 |
19 | if !s.readOnly {
20 | s.addToolIfExists(ToolKubernetesProxy, s.HandleKubernetesProxy())
21 | }
22 | }
23 |
24 | func (s *PortainerMCPServer) HandleKubernetesProxyStripped() server.ToolHandlerFunc {
25 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
26 | parser := toolgen.NewParameterParser(request)
27 |
28 | environmentId, err := parser.GetInt("environmentId", true)
29 | if err != nil {
30 | return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
31 | }
32 |
33 | kubernetesAPIPath, err := parser.GetString("kubernetesAPIPath", true)
34 | if err != nil {
35 | return mcp.NewToolResultErrorFromErr("invalid kubernetesAPIPath parameter", err), nil
36 | }
37 | if !strings.HasPrefix(kubernetesAPIPath, "/") {
38 | return mcp.NewToolResultError("kubernetesAPIPath must start with a leading slash"), nil
39 | }
40 |
41 | queryParams, err := parser.GetArrayOfObjects("queryParams", false)
42 | if err != nil {
43 | return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
44 | }
45 | queryParamsMap, err := parseKeyValueMap(queryParams)
46 | if err != nil {
47 | return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
48 | }
49 |
50 | headers, err := parser.GetArrayOfObjects("headers", false)
51 | if err != nil {
52 | return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
53 | }
54 | headersMap, err := parseKeyValueMap(headers)
55 | if err != nil {
56 | return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
57 | }
58 |
59 | opts := models.KubernetesProxyRequestOptions{
60 | EnvironmentID: environmentId,
61 | Path: kubernetesAPIPath,
62 | Method: "GET",
63 | QueryParams: queryParamsMap,
64 | Headers: headersMap,
65 | }
66 |
67 | response, err := s.cli.ProxyKubernetesRequest(opts)
68 | if err != nil {
69 | return mcp.NewToolResultErrorFromErr("failed to send Kubernetes API request", err), nil
70 | }
71 |
72 | responseBody, err := k8sutil.ProcessRawKubernetesAPIResponse(response)
73 | if err != nil {
74 | return mcp.NewToolResultErrorFromErr("failed to process Kubernetes API response", err), nil
75 | }
76 |
77 | return mcp.NewToolResultText(string(responseBody)), nil
78 | }
79 | }
80 |
81 | func (s *PortainerMCPServer) HandleKubernetesProxy() server.ToolHandlerFunc {
82 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
83 | parser := toolgen.NewParameterParser(request)
84 |
85 | environmentId, err := parser.GetInt("environmentId", true)
86 | if err != nil {
87 | return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
88 | }
89 |
90 | method, err := parser.GetString("method", true)
91 | if err != nil {
92 | return mcp.NewToolResultErrorFromErr("invalid method parameter", err), nil
93 | }
94 | if !isValidHTTPMethod(method) {
95 | return mcp.NewToolResultError(fmt.Sprintf("invalid method: %s", method)), nil
96 | }
97 |
98 | kubernetesAPIPath, err := parser.GetString("kubernetesAPIPath", true)
99 | if err != nil {
100 | return mcp.NewToolResultErrorFromErr("invalid kubernetesAPIPath parameter", err), nil
101 | }
102 | if !strings.HasPrefix(kubernetesAPIPath, "/") {
103 | return mcp.NewToolResultError("kubernetesAPIPath must start with a leading slash"), nil
104 | }
105 |
106 | queryParams, err := parser.GetArrayOfObjects("queryParams", false)
107 | if err != nil {
108 | return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
109 | }
110 | queryParamsMap, err := parseKeyValueMap(queryParams)
111 | if err != nil {
112 | return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
113 | }
114 |
115 | headers, err := parser.GetArrayOfObjects("headers", false)
116 | if err != nil {
117 | return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
118 | }
119 | headersMap, err := parseKeyValueMap(headers)
120 | if err != nil {
121 | return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
122 | }
123 |
124 | body, err := parser.GetString("body", false)
125 | if err != nil {
126 | return mcp.NewToolResultErrorFromErr("invalid body parameter", err), nil
127 | }
128 |
129 | opts := models.KubernetesProxyRequestOptions{
130 | EnvironmentID: environmentId,
131 | Path: kubernetesAPIPath,
132 | Method: method,
133 | QueryParams: queryParamsMap,
134 | Headers: headersMap,
135 | }
136 |
137 | if body != "" {
138 | opts.Body = strings.NewReader(body)
139 | }
140 |
141 | response, err := s.cli.ProxyKubernetesRequest(opts)
142 | if err != nil {
143 | return mcp.NewToolResultErrorFromErr("failed to send Kubernetes API request", err), nil
144 | }
145 |
146 | responseBody, err := io.ReadAll(response.Body)
147 | if err != nil {
148 | return mcp.NewToolResultErrorFromErr("failed to read Kubernetes API response", err), nil
149 | }
150 |
151 | return mcp.NewToolResultText(string(responseBody)), nil
152 | }
153 | }
154 |
```
--------------------------------------------------------------------------------
/internal/mcp/server_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "testing"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestNewPortainerMCPServer(t *testing.T) {
15 | // Define paths to test data files
16 | validToolsPath := "testdata/valid_tools.yaml"
17 | invalidToolsPath := "testdata/invalid_tools.yaml"
18 |
19 | tests := []struct {
20 | name string
21 | serverURL string
22 | token string
23 | toolsPath string
24 | mockSetup func(*MockPortainerClient)
25 | expectError bool
26 | errorContains string
27 | }{
28 | {
29 | name: "successful initialization with supported version",
30 | serverURL: "https://portainer.example.com",
31 | token: "valid-token",
32 | toolsPath: validToolsPath,
33 | mockSetup: func(m *MockPortainerClient) {
34 | m.On("GetVersion").Return(SupportedPortainerVersion, nil)
35 | },
36 | expectError: false,
37 | },
38 | {
39 | name: "invalid tools path",
40 | serverURL: "https://portainer.example.com",
41 | token: "valid-token",
42 | toolsPath: "testdata/nonexistent.yaml",
43 | mockSetup: func(m *MockPortainerClient) {},
44 | expectError: true,
45 | errorContains: "failed to load tools",
46 | },
47 | {
48 | name: "invalid tools version",
49 | serverURL: "https://portainer.example.com",
50 | token: "valid-token",
51 | toolsPath: invalidToolsPath,
52 | mockSetup: func(m *MockPortainerClient) {},
53 | expectError: true,
54 | errorContains: "invalid version in tools.yaml",
55 | },
56 | {
57 | name: "API communication error",
58 | serverURL: "https://portainer.example.com",
59 | token: "valid-token",
60 | toolsPath: validToolsPath,
61 | mockSetup: func(m *MockPortainerClient) {
62 | m.On("GetVersion").Return("", errors.New("connection error"))
63 | },
64 | expectError: true,
65 | errorContains: "failed to get Portainer server version",
66 | },
67 | {
68 | name: "unsupported Portainer version",
69 | serverURL: "https://portainer.example.com",
70 | token: "valid-token",
71 | toolsPath: validToolsPath,
72 | mockSetup: func(m *MockPortainerClient) {
73 | m.On("GetVersion").Return("2.0.0", nil)
74 | },
75 | expectError: true,
76 | errorContains: "unsupported Portainer server version",
77 | },
78 | {
79 | name: "unsupported version with disabled version check",
80 | serverURL: "https://portainer.example.com",
81 | token: "valid-token",
82 | toolsPath: validToolsPath,
83 | mockSetup: func(m *MockPortainerClient) {
84 | // No GetVersion call expected when version check is disabled
85 | },
86 | expectError: false,
87 | },
88 | }
89 |
90 | for _, tt := range tests {
91 | t.Run(tt.name, func(t *testing.T) {
92 | // Create and configure the mock client
93 | mockClient := new(MockPortainerClient)
94 | tt.mockSetup(mockClient)
95 |
96 | // Create server with mock client using the WithClient option
97 | var options []ServerOption
98 | options = append(options, WithClient(mockClient))
99 |
100 | // Add WithDisableVersionCheck for the specific test case
101 | if tt.name == "unsupported version with disabled version check" {
102 | options = append(options, WithDisableVersionCheck(true))
103 | }
104 |
105 | server, err := NewPortainerMCPServer(
106 | tt.serverURL,
107 | tt.token,
108 | tt.toolsPath,
109 | options...,
110 | )
111 |
112 | if tt.expectError {
113 | assert.Error(t, err)
114 | if tt.errorContains != "" {
115 | assert.Contains(t, err.Error(), tt.errorContains)
116 | }
117 | assert.Nil(t, server)
118 | } else {
119 | require.NoError(t, err)
120 | assert.NotNil(t, server)
121 | assert.NotNil(t, server.srv)
122 | assert.NotNil(t, server.cli)
123 | assert.NotNil(t, server.tools)
124 | }
125 |
126 | // Verify that all expected methods were called
127 | mockClient.AssertExpectations(t)
128 | })
129 | }
130 | }
131 |
132 | func TestAddToolIfExists(t *testing.T) {
133 | tests := []struct {
134 | name string
135 | tools map[string]mcp.Tool
136 | toolName string
137 | exists bool
138 | }{
139 | {
140 | name: "existing tool",
141 | tools: map[string]mcp.Tool{
142 | "test_tool": {
143 | Name: "test_tool",
144 | Description: "Test tool description",
145 | InputSchema: mcp.ToolInputSchema{
146 | Properties: map[string]any{},
147 | },
148 | },
149 | },
150 | toolName: "test_tool",
151 | exists: true,
152 | },
153 | {
154 | name: "non-existing tool",
155 | tools: map[string]mcp.Tool{
156 | "test_tool": {
157 | Name: "test_tool",
158 | Description: "Test tool description",
159 | InputSchema: mcp.ToolInputSchema{
160 | Properties: map[string]any{},
161 | },
162 | },
163 | },
164 | toolName: "nonexistent_tool",
165 | exists: false,
166 | },
167 | }
168 |
169 | for _, tt := range tests {
170 | t.Run(tt.name, func(t *testing.T) {
171 | // Create server with test tools
172 | mcpServer := server.NewMCPServer(
173 | "Test Server",
174 | "1.0.0",
175 | server.WithResourceCapabilities(true, true),
176 | server.WithLogging(),
177 | )
178 | server := &PortainerMCPServer{
179 | tools: tt.tools,
180 | srv: mcpServer,
181 | }
182 |
183 | // Create a handler function
184 | handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
185 | return &mcp.CallToolResult{}, nil
186 | }
187 |
188 | // Call addToolIfExists
189 | server.addToolIfExists(tt.toolName, handler)
190 |
191 | // Verify if the tool exists in the tools map
192 | _, toolExists := server.tools[tt.toolName]
193 | assert.Equal(t, tt.exists, toolExists)
194 | })
195 | }
196 | }
197 |
```
--------------------------------------------------------------------------------
/internal/mcp/user_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "testing"
8 |
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | func TestHandleGetUsers(t *testing.T) {
15 | tests := []struct {
16 | name string
17 | mockUsers []models.User
18 | mockError error
19 | expectError bool
20 | }{
21 | {
22 | name: "successful users retrieval",
23 | mockUsers: []models.User{
24 | {ID: 1, Username: "user1", Role: "admin"},
25 | {ID: 2, Username: "user2", Role: "user"},
26 | },
27 | mockError: nil,
28 | expectError: false,
29 | },
30 | {
31 | name: "api error",
32 | mockUsers: nil,
33 | mockError: fmt.Errorf("api error"),
34 | expectError: true,
35 | },
36 | }
37 |
38 | for _, tt := range tests {
39 | t.Run(tt.name, func(t *testing.T) {
40 | // Create mock client
41 | mockClient := &MockPortainerClient{}
42 | mockClient.On("GetUsers").Return(tt.mockUsers, tt.mockError)
43 |
44 | // Create server with mock client
45 | server := &PortainerMCPServer{
46 | cli: mockClient,
47 | }
48 |
49 | // Call handler
50 | handler := server.HandleGetUsers()
51 | result, err := handler(context.Background(), mcp.CallToolRequest{})
52 |
53 | // Verify results
54 | if tt.expectError {
55 | assert.NoError(t, err)
56 | assert.NotNil(t, result)
57 | assert.True(t, result.IsError, "result.IsError should be true for API errors")
58 | assert.Len(t, result.Content, 1)
59 | textContent, ok := result.Content[0].(mcp.TextContent)
60 | assert.True(t, ok, "Result content should be mcp.TextContent")
61 | if tt.mockError != nil {
62 | assert.Contains(t, textContent.Text, tt.mockError.Error())
63 | }
64 | } else {
65 | assert.NoError(t, err)
66 | assert.Len(t, result.Content, 1)
67 | textContent, ok := result.Content[0].(mcp.TextContent)
68 | assert.True(t, ok)
69 |
70 | var users []models.User
71 | err = json.Unmarshal([]byte(textContent.Text), &users)
72 | assert.NoError(t, err)
73 | assert.Equal(t, tt.mockUsers, users)
74 | }
75 |
76 | // Verify mock expectations
77 | mockClient.AssertExpectations(t)
78 | })
79 | }
80 | }
81 |
82 | func TestHandleUpdateUserRole(t *testing.T) {
83 | tests := []struct {
84 | name string
85 | inputID int
86 | inputRole string
87 | mockError error
88 | expectError bool
89 | setupParams func(request *mcp.CallToolRequest)
90 | }{
91 | {
92 | name: "successful role update",
93 | inputID: 1,
94 | inputRole: "admin",
95 | mockError: nil,
96 | expectError: false,
97 | setupParams: func(request *mcp.CallToolRequest) {
98 | request.Params.Arguments = map[string]any{
99 | "id": float64(1),
100 | "role": "admin",
101 | }
102 | },
103 | },
104 | {
105 | name: "api error",
106 | inputID: 1,
107 | inputRole: "admin",
108 | mockError: fmt.Errorf("api error"),
109 | expectError: true,
110 | setupParams: func(request *mcp.CallToolRequest) {
111 | request.Params.Arguments = map[string]any{
112 | "id": float64(1),
113 | "role": "admin",
114 | }
115 | },
116 | },
117 | {
118 | name: "missing id parameter",
119 | inputID: 0,
120 | inputRole: "admin",
121 | mockError: nil,
122 | expectError: true,
123 | setupParams: func(request *mcp.CallToolRequest) {
124 | request.Params.Arguments = map[string]any{
125 | "role": "admin",
126 | }
127 | },
128 | },
129 | {
130 | name: "missing role parameter",
131 | inputID: 1,
132 | inputRole: "",
133 | mockError: nil,
134 | expectError: true,
135 | setupParams: func(request *mcp.CallToolRequest) {
136 | request.Params.Arguments = map[string]any{
137 | "id": float64(1),
138 | }
139 | },
140 | },
141 | {
142 | name: "invalid role",
143 | inputID: 1,
144 | inputRole: "invalid_role",
145 | mockError: nil,
146 | expectError: true,
147 | setupParams: func(request *mcp.CallToolRequest) {
148 | request.Params.Arguments = map[string]any{
149 | "id": float64(1),
150 | "role": "invalid_role",
151 | }
152 | },
153 | },
154 | }
155 |
156 | for _, tt := range tests {
157 | t.Run(tt.name, func(t *testing.T) {
158 | // Create mock client
159 | mockClient := &MockPortainerClient{}
160 | if !tt.expectError || tt.mockError != nil {
161 | mockClient.On("UpdateUserRole", tt.inputID, tt.inputRole).Return(tt.mockError)
162 | }
163 |
164 | // Create server with mock client
165 | server := &PortainerMCPServer{
166 | cli: mockClient,
167 | }
168 |
169 | // Create request with parameters
170 | request := CreateMCPRequest(map[string]any{})
171 | tt.setupParams(&request)
172 |
173 | // Call handler
174 | handler := server.HandleUpdateUserRole()
175 | result, err := handler(context.Background(), request)
176 |
177 | // Verify results
178 | if tt.expectError {
179 | assert.NoError(t, err)
180 | assert.NotNil(t, result)
181 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
182 | assert.Len(t, result.Content, 1)
183 | textContent, ok := result.Content[0].(mcp.TextContent)
184 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
185 | if tt.mockError != nil {
186 | assert.Contains(t, textContent.Text, tt.mockError.Error())
187 | } else {
188 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
189 | if tt.inputRole == "invalid_role" {
190 | assert.Contains(t, textContent.Text, "invalid role")
191 | }
192 | }
193 | } else {
194 | assert.NoError(t, err)
195 | assert.Len(t, result.Content, 1)
196 | textContent, ok := result.Content[0].(mcp.TextContent)
197 | assert.True(t, ok)
198 | assert.Contains(t, textContent.Text, "successfully")
199 | }
200 |
201 | // Verify mock expectations
202 | mockClient.AssertExpectations(t)
203 | })
204 | }
205 | }
206 |
```
--------------------------------------------------------------------------------
/cloc.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # This scripts counts the lines of code (LOC) and comments in Go source files
4 | # within this project directory. It uses the commandline tool "cloc".
5 | # Requires `cloc` to be installed (e.g., `sudo apt install cloc` or `brew install cloc`).
6 | # Modified from: https://schneegans.github.io/tutorials/2022/04/18/badges
7 | #
8 | # Usage:
9 | # Run from the repository root:
10 | # ./cloc.sh
11 | #
12 | # Default Output:
13 | # Displays a summary of code statistics:
14 | # Total lines of code: <value>k
15 | # Lines of source code: <value>k
16 | # Lines of comments (source code): <value>k
17 | # Lines of test code: <value>k
18 | # Comment Percentage: <value>%
19 | # Test Percentage: <value>%
20 | #
21 | # Flags for Specific Metrics:
22 | # You can request individual metrics using the following flags:
23 | # --loc : Lines of source code (Go files, excluding tests).
24 | # --comments : Lines of comments in source code.
25 | # --percentage : Comment percentage in source code.
26 | # --test-loc : Lines of test code (_test.go files + tests/integration/ dir).
27 | # --test-percentage : Percentage of test code compared to total code.
28 | # --total-loc : Total lines of code (source + test).
29 | #
30 | # Example:
31 | # ./cloc.sh --test-percentage
32 | # # Output: 19.0 (example value)
33 |
34 | # Get the location of this script.
35 | SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )"
36 |
37 | # Run cloc for source code - this counts code lines, blank lines and comment lines
38 | # for the specified languages, excluding test files.
39 | # We are only interested in the summary, therefore the tail -1
40 | SUMMARY_SRC="$(cloc "${SCRIPT_DIR}" --include-lang="Go" --not-match-f="_test\.go$" --not-match-d="tests/integration" --md | tail -1)"
41 |
42 | # Run cloc for test files ending in _test.go
43 | SUMMARY_TEST_FILES="$(cloc "${SCRIPT_DIR}" --include-lang="Go" --match-f='_test\.go$' --md | tail -1)"
44 |
45 | # Run cloc for the tests/integration directory if it exists
46 | SUMMARY_TEST_DIR=""
47 | if [[ -d "${SCRIPT_DIR}/tests/integration" ]]; then
48 | SUMMARY_TEST_DIR="$(cloc "${SCRIPT_DIR}/tests/integration" --include-lang="Go" --md | tail -1)"
49 | fi
50 |
51 |
52 | # The SUMMARY strings are lines of a markdown table and look like this:
53 | # SUM:|files|blank|comment|code
54 | # We use the following command to split it into an array.
55 | IFS='|' read -r -a TOKENS_SRC <<< "$SUMMARY_SRC"
56 | IFS='|' read -r -a TOKENS_TEST_FILES <<< "$SUMMARY_TEST_FILES"
57 | IFS='|' read -r -a TOKENS_TEST_DIR <<< "$SUMMARY_TEST_DIR"
58 |
59 | # Store the individual tokens for better readability.
60 | # Source Code
61 | NUMBER_OF_FILES_SRC=${TOKENS_SRC[1]:-0} # Default to 0 if empty
62 | COMMENT_LINES_SRC=${TOKENS_SRC[3]:-0}
63 | LINES_OF_CODE_SRC=${TOKENS_SRC[4]:-0}
64 |
65 | # Test Code (_test.go files)
66 | LINES_OF_CODE_TEST_FILES=${TOKENS_TEST_FILES[4]:-0}
67 |
68 | # Test Code (tests/integration dir)
69 | LINES_OF_CODE_TEST_DIR=${TOKENS_TEST_DIR[4]:-0}
70 |
71 | # Total Test Code
72 | LINES_OF_TEST_CODE=$((LINES_OF_CODE_TEST_FILES + LINES_OF_CODE_TEST_DIR))
73 |
74 | # Total Code (Source + Test)
75 | TOTAL_LINES_OF_CODE=$((LINES_OF_CODE_SRC + LINES_OF_TEST_CODE))
76 |
77 |
78 | # Print all results if no arguments are given.
79 | if [[ $# -eq 0 ]] ; then
80 | awk -v loc_src=$LINES_OF_CODE_SRC \
81 | -v comments_src=$COMMENT_LINES_SRC \
82 | -v loc_test=$LINES_OF_TEST_CODE \
83 | -v loc_total=$TOTAL_LINES_OF_CODE \
84 | 'BEGIN {
85 | label_width = 35 # Define a width for the labels
86 | printf "%-*s %6.1fk\n", label_width, "Total lines of code:", loc_total/1000;
87 | printf "%-*s %6.1fk\n", label_width, "Lines of source code:", loc_src/1000;
88 | printf "%-*s %6.1fk\n", label_width, "Lines of comments (source code):", comments_src/1000;
89 | printf "%-*s %6.1fk\n", label_width, "Lines of test code:", loc_test/1000;
90 | if (loc_src + comments_src > 0) {
91 | printf "%-*s %6.1f%%\n", label_width, "Comment Percentage:", 100*comments_src/(loc_src + comments_src);
92 | } else {
93 | printf "%-*s %6s\n", label_width, "Comment Percentage:", "N/A"; # Adjusted N/A alignment
94 | }
95 | if (loc_src + loc_test > 0) {
96 | printf "%-*s %6.1f%%\n", label_width, "Test Percentage:", 100*loc_test/(loc_src + loc_test);
97 | } else {
98 | printf "%-*s %6s\n", label_width, "Test Percentage:", "N/A"; # Adjusted N/A alignment
99 | }
100 | }'
101 | exit 0
102 | fi
103 |
104 | # --- Argument Parsing ---
105 |
106 | # Show lines of source code if --loc is given.
107 | if [[ $* == *--loc* ]]
108 | then
109 | awk -v a=$LINES_OF_CODE_SRC \
110 | 'BEGIN {printf "%.1fk\n", a/1000}'
111 | fi
112 |
113 | # Show lines of comments if --comments is given.
114 | if [[ $* == *--comments* ]]
115 | then
116 | awk -v a=$COMMENT_LINES_SRC \
117 | 'BEGIN {printf "%.1fk\n", a/1000}'
118 | fi
119 |
120 | # Show percentage of comments if --percentage is given.
121 | if [[ $* == *--percentage* ]]
122 | then
123 | awk -v a=$COMMENT_LINES_SRC -v b=$LINES_OF_CODE_SRC \
124 | 'BEGIN {if (a+b > 0) printf "%.1f\n", 100*a/(a+b); else print "N/A"}'
125 | fi
126 |
127 | # Show lines of test code if --test-loc is given.
128 | if [[ $* == *--test-loc* ]]
129 | then
130 | awk -v a=$LINES_OF_TEST_CODE \
131 | 'BEGIN {printf "%.1fk\n", a/1000}'
132 | fi
133 |
134 | # Show test percentage if --test-percentage is given.
135 | if [[ $* == *--test-percentage* ]]
136 | then
137 | awk -v a=$LINES_OF_TEST_CODE -v b=$LINES_OF_CODE_SRC \
138 | 'BEGIN {if (a+b > 0) printf "%.1f\n", 100*a/(a+b); else print "N/A"}'
139 | fi
140 |
141 | # Show total lines of code if --total-loc is given.
142 | if [[ $* == *--total-loc* ]]
143 | then
144 | awk -v a=$TOTAL_LINES_OF_CODE \
145 | 'BEGIN {printf "%.1fk\n", a/1000}'
146 | fi
```
--------------------------------------------------------------------------------
/pkg/portainer/client/stack_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "errors"
5 | "testing"
6 | "time"
7 |
8 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
9 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/utils"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | func TestGetStacks(t *testing.T) {
15 | now := time.Now().Unix()
16 | tests := []struct {
17 | name string
18 | mockStacks []*apimodels.PortainereeEdgeStack
19 | mockError error
20 | expected []models.Stack
21 | expectedError bool
22 | }{
23 | {
24 | name: "successful retrieval",
25 | mockStacks: []*apimodels.PortainereeEdgeStack{
26 | {
27 | ID: 1,
28 | Name: "stack1",
29 | CreationDate: now,
30 | EdgeGroups: []int64{1, 2},
31 | },
32 | {
33 | ID: 2,
34 | Name: "stack2",
35 | CreationDate: now,
36 | EdgeGroups: []int64{3},
37 | },
38 | },
39 | expected: []models.Stack{
40 | {
41 | ID: 1,
42 | Name: "stack1",
43 | CreatedAt: time.Unix(now, 0).Format(time.RFC3339),
44 | EnvironmentGroupIds: []int{1, 2},
45 | },
46 | {
47 | ID: 2,
48 | Name: "stack2",
49 | CreatedAt: time.Unix(now, 0).Format(time.RFC3339),
50 | EnvironmentGroupIds: []int{3},
51 | },
52 | },
53 | },
54 | {
55 | name: "empty stacks",
56 | mockStacks: []*apimodels.PortainereeEdgeStack{},
57 | expected: []models.Stack{},
58 | },
59 | {
60 | name: "list error",
61 | mockError: errors.New("failed to list stacks"),
62 | expectedError: true,
63 | },
64 | }
65 |
66 | for _, tt := range tests {
67 | t.Run(tt.name, func(t *testing.T) {
68 | mockAPI := new(MockPortainerAPI)
69 | mockAPI.On("ListEdgeStacks").Return(tt.mockStacks, tt.mockError)
70 |
71 | client := &PortainerClient{cli: mockAPI}
72 |
73 | stacks, err := client.GetStacks()
74 |
75 | if tt.expectedError {
76 | assert.Error(t, err)
77 | return
78 | }
79 | assert.NoError(t, err)
80 | assert.Equal(t, tt.expected, stacks)
81 | mockAPI.AssertExpectations(t)
82 | })
83 | }
84 | }
85 |
86 | func TestGetStackFile(t *testing.T) {
87 | tests := []struct {
88 | name string
89 | stackID int
90 | mockFile string
91 | mockError error
92 | expected string
93 | expectedError bool
94 | }{
95 | {
96 | name: "successful retrieval",
97 | stackID: 1,
98 | mockFile: "version: '3'\nservices:\n web:\n image: nginx",
99 | expected: "version: '3'\nservices:\n web:\n image: nginx",
100 | },
101 | {
102 | name: "get file error",
103 | stackID: 2,
104 | mockError: errors.New("failed to get stack file"),
105 | expectedError: true,
106 | },
107 | }
108 |
109 | for _, tt := range tests {
110 | t.Run(tt.name, func(t *testing.T) {
111 | mockAPI := new(MockPortainerAPI)
112 | mockAPI.On("GetEdgeStackFile", int64(tt.stackID)).Return(tt.mockFile, tt.mockError)
113 |
114 | client := &PortainerClient{cli: mockAPI}
115 |
116 | file, err := client.GetStackFile(tt.stackID)
117 |
118 | if tt.expectedError {
119 | assert.Error(t, err)
120 | return
121 | }
122 | assert.NoError(t, err)
123 | assert.Equal(t, tt.expected, file)
124 | mockAPI.AssertExpectations(t)
125 | })
126 | }
127 | }
128 |
129 | func TestCreateStack(t *testing.T) {
130 | tests := []struct {
131 | name string
132 | stackName string
133 | stackFile string
134 | environmentGroupIds []int
135 | mockID int64
136 | mockError error
137 | expected int
138 | expectedError bool
139 | }{
140 | {
141 | name: "successful creation",
142 | stackName: "test-stack",
143 | stackFile: "version: '3'\nservices:\n web:\n image: nginx",
144 | environmentGroupIds: []int{1, 2},
145 | mockID: 1,
146 | expected: 1,
147 | },
148 | {
149 | name: "create error",
150 | stackName: "test-stack",
151 | stackFile: "version: '3'\nservices:\n web:\n image: nginx",
152 | environmentGroupIds: []int{1},
153 | mockError: errors.New("failed to create stack"),
154 | expectedError: true,
155 | },
156 | }
157 |
158 | for _, tt := range tests {
159 | t.Run(tt.name, func(t *testing.T) {
160 | mockAPI := new(MockPortainerAPI)
161 | mockAPI.On("CreateEdgeStack", tt.stackName, tt.stackFile, utils.IntToInt64Slice(tt.environmentGroupIds)).Return(tt.mockID, tt.mockError)
162 |
163 | client := &PortainerClient{cli: mockAPI}
164 |
165 | id, err := client.CreateStack(tt.stackName, tt.stackFile, tt.environmentGroupIds)
166 |
167 | if tt.expectedError {
168 | assert.Error(t, err)
169 | return
170 | }
171 | assert.NoError(t, err)
172 | assert.Equal(t, tt.expected, id)
173 | mockAPI.AssertExpectations(t)
174 | })
175 | }
176 | }
177 |
178 | func TestUpdateStack(t *testing.T) {
179 | tests := []struct {
180 | name string
181 | stackID int
182 | stackFile string
183 | environmentGroupIds []int
184 | mockError error
185 | expectedError bool
186 | }{
187 | {
188 | name: "successful update",
189 | stackID: 1,
190 | stackFile: "version: '3'\nservices:\n web:\n image: nginx:latest",
191 | environmentGroupIds: []int{1, 2},
192 | },
193 | {
194 | name: "update error",
195 | stackID: 2,
196 | stackFile: "version: '3'\nservices:\n web:\n image: nginx:latest",
197 | environmentGroupIds: []int{1},
198 | mockError: errors.New("failed to update stack"),
199 | expectedError: true,
200 | },
201 | }
202 |
203 | for _, tt := range tests {
204 | t.Run(tt.name, func(t *testing.T) {
205 | mockAPI := new(MockPortainerAPI)
206 | mockAPI.On("UpdateEdgeStack", int64(tt.stackID), tt.stackFile, utils.IntToInt64Slice(tt.environmentGroupIds)).Return(tt.mockError)
207 |
208 | client := &PortainerClient{cli: mockAPI}
209 |
210 | err := client.UpdateStack(tt.stackID, tt.stackFile, tt.environmentGroupIds)
211 |
212 | if tt.expectedError {
213 | assert.Error(t, err)
214 | return
215 | }
216 | assert.NoError(t, err)
217 | mockAPI.AssertExpectations(t)
218 | })
219 | }
220 | }
221 |
```
--------------------------------------------------------------------------------
/pkg/portainer/models/environment_test.go:
--------------------------------------------------------------------------------
```go
1 | package models
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | "github.com/portainer/client-api-go/v2/pkg/models"
8 | )
9 |
10 | func TestConvertEndpointToEnvironment(t *testing.T) {
11 | tests := []struct {
12 | name string
13 | endpoint *models.PortainereeEndpoint
14 | want Environment
15 | }{
16 | {
17 | name: "active docker-local environment with accesses",
18 | endpoint: &models.PortainereeEndpoint{
19 | ID: 1,
20 | Name: "local-docker",
21 | Status: 1, // active
22 | Type: 1, // docker-local
23 | TagIds: []int64{1, 2},
24 | UserAccessPolicies: models.PortainerUserAccessPolicies{
25 | "1": models.PortainerAccessPolicy{RoleID: 1},
26 | "2": models.PortainerAccessPolicy{RoleID: 3},
27 | },
28 | TeamAccessPolicies: models.PortainerTeamAccessPolicies{
29 | "10": models.PortainerAccessPolicy{RoleID: 2},
30 | "20": models.PortainerAccessPolicy{RoleID: 4},
31 | },
32 | },
33 | want: Environment{
34 | ID: 1,
35 | Name: "local-docker",
36 | Status: EnvironmentStatusActive,
37 | Type: EnvironmentTypeDockerLocal,
38 | TagIds: []int{1, 2},
39 | UserAccesses: map[int]string{
40 | 1: "environment_administrator",
41 | 2: "standard_user",
42 | },
43 | TeamAccesses: map[int]string{
44 | 10: "helpdesk_user",
45 | 20: "readonly_user",
46 | },
47 | },
48 | },
49 | {
50 | name: "inactive kubernetes-agent environment with empty accesses",
51 | endpoint: &models.PortainereeEndpoint{
52 | ID: 2,
53 | Name: "k8s-agent",
54 | Status: 2, // inactive
55 | Type: 7, // kubernetes-edge-agent
56 | TagIds: []int64{1},
57 | UserAccessPolicies: models.PortainerUserAccessPolicies{},
58 | TeamAccessPolicies: models.PortainerTeamAccessPolicies{},
59 | },
60 | want: Environment{
61 | ID: 2,
62 | Name: "k8s-agent",
63 | Status: EnvironmentStatusInactive,
64 | Type: EnvironmentTypeKubernetesEdgeAgent,
65 | TagIds: []int{1},
66 | UserAccesses: map[int]string{},
67 | TeamAccesses: map[int]string{},
68 | },
69 | },
70 | {
71 | name: "environment with invalid access IDs",
72 | endpoint: &models.PortainereeEndpoint{
73 | ID: 3,
74 | Name: "invalid-access",
75 | Status: 1,
76 | Type: 1,
77 | TagIds: []int64{},
78 | UserAccessPolicies: models.PortainerUserAccessPolicies{
79 | "invalid": models.PortainerAccessPolicy{RoleID: 1},
80 | "2": models.PortainerAccessPolicy{RoleID: 3},
81 | },
82 | TeamAccessPolicies: models.PortainerTeamAccessPolicies{
83 | "bad": models.PortainerAccessPolicy{RoleID: 2},
84 | "20": models.PortainerAccessPolicy{RoleID: 4},
85 | },
86 | },
87 | want: Environment{
88 | ID: 3,
89 | Name: "invalid-access",
90 | Status: EnvironmentStatusActive,
91 | Type: EnvironmentTypeDockerLocal,
92 | TagIds: []int{},
93 | UserAccesses: map[int]string{
94 | 2: "standard_user",
95 | },
96 | TeamAccesses: map[int]string{
97 | 20: "readonly_user",
98 | },
99 | },
100 | },
101 | }
102 |
103 | for _, tt := range tests {
104 | t.Run(tt.name, func(t *testing.T) {
105 | got := ConvertEndpointToEnvironment(tt.endpoint)
106 | if !reflect.DeepEqual(got, tt.want) {
107 | t.Errorf("ConvertEndpointToEnvironment() = %v, want %v", got, tt.want)
108 | }
109 | })
110 | }
111 | }
112 |
113 | func TestConvertEnvironmentStatus(t *testing.T) {
114 | tests := []struct {
115 | name string
116 | endpoint *models.PortainereeEndpoint
117 | want string
118 | }{
119 | {
120 | name: "standard environment - active status",
121 | endpoint: &models.PortainereeEndpoint{
122 | Status: 1,
123 | Type: 1, // docker-local
124 | },
125 | want: EnvironmentStatusActive,
126 | },
127 | {
128 | name: "standard environment - inactive status",
129 | endpoint: &models.PortainereeEndpoint{
130 | Status: 2,
131 | Type: 2, // docker-agent
132 | },
133 | want: EnvironmentStatusInactive,
134 | },
135 | {
136 | name: "standard environment - unknown status",
137 | endpoint: &models.PortainereeEndpoint{
138 | Status: 0,
139 | Type: 3, // azure-aci
140 | },
141 | want: EnvironmentStatusUnknown,
142 | },
143 | {
144 | name: "edge environment - active with heartbeat",
145 | endpoint: &models.PortainereeEndpoint{
146 | Type: 4, // docker-edge-agent
147 | Heartbeat: true,
148 | },
149 | want: EnvironmentStatusActive,
150 | },
151 | {
152 | name: "edge environment - inactive without heartbeat",
153 | endpoint: &models.PortainereeEndpoint{
154 | Type: 7, // kubernetes-edge-agent
155 | Heartbeat: false,
156 | },
157 | want: EnvironmentStatusInactive,
158 | },
159 | }
160 |
161 | for _, tt := range tests {
162 | t.Run(tt.name, func(t *testing.T) {
163 | got := convertEnvironmentStatus(tt.endpoint)
164 | if got != tt.want {
165 | t.Errorf("convertEnvironmentStatus() = %v, want %v", got, tt.want)
166 | }
167 | })
168 | }
169 | }
170 |
171 | func TestConvertEnvironmentType(t *testing.T) {
172 | tests := []struct {
173 | name string
174 | typeValue int
175 | want string
176 | }{
177 | {
178 | name: "docker-local type",
179 | typeValue: 1,
180 | want: EnvironmentTypeDockerLocal,
181 | },
182 | {
183 | name: "docker-agent type",
184 | typeValue: 2,
185 | want: EnvironmentTypeDockerAgent,
186 | },
187 | {
188 | name: "azure-aci type",
189 | typeValue: 3,
190 | want: EnvironmentTypeAzureACI,
191 | },
192 | {
193 | name: "docker-edge-agent type",
194 | typeValue: 4,
195 | want: EnvironmentTypeDockerEdgeAgent,
196 | },
197 | {
198 | name: "kubernetes-local type",
199 | typeValue: 5,
200 | want: EnvironmentTypeKubernetesLocal,
201 | },
202 | {
203 | name: "kubernetes-agent type",
204 | typeValue: 6,
205 | want: EnvironmentTypeKubernetesAgent,
206 | },
207 | {
208 | name: "kubernetes-edge-agent type",
209 | typeValue: 7,
210 | want: EnvironmentTypeKubernetesEdgeAgent,
211 | },
212 | {
213 | name: "unknown type",
214 | typeValue: 0,
215 | want: EnvironmentTypeUnknown,
216 | },
217 | {
218 | name: "invalid type",
219 | typeValue: 99,
220 | want: EnvironmentTypeUnknown,
221 | },
222 | }
223 |
224 | for _, tt := range tests {
225 | t.Run(tt.name, func(t *testing.T) {
226 | endpoint := &models.PortainereeEndpoint{Type: int64(tt.typeValue)}
227 | got := convertEnvironmentType(endpoint)
228 | if got != tt.want {
229 | t.Errorf("convertEnvironmentType() = %v, want %v", got, tt.want)
230 | }
231 | })
232 | }
233 | }
234 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/group_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
8 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/mock"
11 | )
12 |
13 | func TestGetEnvironmentGroups(t *testing.T) {
14 | tests := []struct {
15 | name string
16 | mockGroups []*apimodels.EdgegroupsDecoratedEdgeGroup
17 | mockError error
18 | expected []models.Group
19 | expectedError bool
20 | }{
21 | {
22 | name: "successful retrieval",
23 | mockGroups: []*apimodels.EdgegroupsDecoratedEdgeGroup{
24 | {
25 | ID: 1,
26 | Name: "group1",
27 | Endpoints: []int64{1, 2},
28 | TagIds: []int64{1, 2},
29 | },
30 | {
31 | ID: 2,
32 | Name: "group2",
33 | Endpoints: []int64{3},
34 | TagIds: []int64{3},
35 | },
36 | },
37 | expected: []models.Group{
38 | {
39 | ID: 1,
40 | Name: "group1",
41 | EnvironmentIds: []int{1, 2},
42 | TagIds: []int{1, 2},
43 | },
44 | {
45 | ID: 2,
46 | Name: "group2",
47 | EnvironmentIds: []int{3},
48 | TagIds: []int{3},
49 | },
50 | },
51 | },
52 | {
53 | name: "empty groups",
54 | mockGroups: []*apimodels.EdgegroupsDecoratedEdgeGroup{},
55 | expected: []models.Group{},
56 | },
57 | {
58 | name: "list error",
59 | mockError: errors.New("failed to list edge groups"),
60 | expectedError: true,
61 | },
62 | }
63 |
64 | for _, tt := range tests {
65 | t.Run(tt.name, func(t *testing.T) {
66 | mockAPI := new(MockPortainerAPI)
67 | mockAPI.On("ListEdgeGroups").Return(tt.mockGroups, tt.mockError)
68 |
69 | client := &PortainerClient{cli: mockAPI}
70 |
71 | groups, err := client.GetEnvironmentGroups()
72 |
73 | if tt.expectedError {
74 | assert.Error(t, err)
75 | return
76 | }
77 | assert.NoError(t, err)
78 | assert.Equal(t, tt.expected, groups)
79 | mockAPI.AssertExpectations(t)
80 | })
81 | }
82 | }
83 |
84 | func TestCreateEnvironmentGroup(t *testing.T) {
85 | tests := []struct {
86 | name string
87 | groupName string
88 | environmentIds []int
89 | mockID int64
90 | mockError error
91 | expectedID int
92 | expectedError bool
93 | }{
94 | {
95 | name: "successful creation",
96 | groupName: "new-group",
97 | environmentIds: []int{1, 2, 3},
98 | mockID: 1,
99 | expectedID: 1,
100 | },
101 | {
102 | name: "creation error",
103 | groupName: "error-group",
104 | environmentIds: []int{1},
105 | mockError: errors.New("failed to create group"),
106 | expectedError: true,
107 | },
108 | {
109 | name: "empty environments",
110 | groupName: "empty-group",
111 | environmentIds: []int{},
112 | mockID: 2,
113 | expectedID: 2,
114 | },
115 | }
116 |
117 | for _, tt := range tests {
118 | t.Run(tt.name, func(t *testing.T) {
119 | mockAPI := new(MockPortainerAPI)
120 | mockAPI.On("CreateEdgeGroup", tt.groupName, mock.Anything).Return(tt.mockID, tt.mockError)
121 |
122 | client := &PortainerClient{cli: mockAPI}
123 |
124 | id, err := client.CreateEnvironmentGroup(tt.groupName, tt.environmentIds)
125 |
126 | if tt.expectedError {
127 | assert.Error(t, err)
128 | return
129 | }
130 | assert.NoError(t, err)
131 | assert.Equal(t, tt.expectedID, id)
132 | mockAPI.AssertExpectations(t)
133 | })
134 | }
135 | }
136 |
137 | func TestUpdateEnvironmentGroupName(t *testing.T) {
138 | tests := []struct {
139 | name string
140 | groupID int
141 | newName string
142 | mockError error
143 | expectedError bool
144 | }{
145 | {
146 | name: "successful update",
147 | groupID: 1,
148 | newName: "updated-group",
149 | },
150 | {
151 | name: "update error",
152 | groupID: 1,
153 | newName: "error-group",
154 | mockError: errors.New("failed to update group name"),
155 | expectedError: true,
156 | },
157 | }
158 |
159 | for _, tt := range tests {
160 | t.Run(tt.name, func(t *testing.T) {
161 | mockAPI := new(MockPortainerAPI)
162 | mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), &tt.newName, mock.Anything, mock.Anything).Return(tt.mockError)
163 |
164 | client := &PortainerClient{cli: mockAPI}
165 |
166 | err := client.UpdateEnvironmentGroupName(tt.groupID, tt.newName)
167 |
168 | if tt.expectedError {
169 | assert.Error(t, err)
170 | return
171 | }
172 | assert.NoError(t, err)
173 | mockAPI.AssertExpectations(t)
174 | })
175 | }
176 | }
177 |
178 | func TestUpdateEnvironmentGroupEnvironments(t *testing.T) {
179 | tests := []struct {
180 | name string
181 | groupID int
182 | environmentIds []int
183 | mockError error
184 | expectedError bool
185 | }{
186 | {
187 | name: "successful update",
188 | groupID: 1,
189 | environmentIds: []int{1, 2, 3},
190 | },
191 | {
192 | name: "update error",
193 | groupID: 1,
194 | environmentIds: []int{1},
195 | mockError: errors.New("failed to update group environments"),
196 | expectedError: true,
197 | },
198 | {
199 | name: "empty environments",
200 | groupID: 1,
201 | environmentIds: []int{},
202 | },
203 | }
204 |
205 | for _, tt := range tests {
206 | t.Run(tt.name, func(t *testing.T) {
207 | mockAPI := new(MockPortainerAPI)
208 | mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
209 |
210 | client := &PortainerClient{cli: mockAPI}
211 |
212 | err := client.UpdateEnvironmentGroupEnvironments(tt.groupID, tt.environmentIds)
213 |
214 | if tt.expectedError {
215 | assert.Error(t, err)
216 | return
217 | }
218 | assert.NoError(t, err)
219 | mockAPI.AssertExpectations(t)
220 | })
221 | }
222 | }
223 |
224 | func TestUpdateEnvironmentGroupTags(t *testing.T) {
225 | tests := []struct {
226 | name string
227 | groupID int
228 | tagIds []int
229 | mockError error
230 | expectedError bool
231 | }{
232 | {
233 | name: "successful update",
234 | groupID: 1,
235 | tagIds: []int{1, 2, 3},
236 | },
237 | {
238 | name: "update error",
239 | groupID: 1,
240 | tagIds: []int{1},
241 | mockError: errors.New("failed to update group tags"),
242 | expectedError: true,
243 | },
244 | {
245 | name: "empty tags",
246 | groupID: 1,
247 | tagIds: []int{},
248 | },
249 | }
250 |
251 | for _, tt := range tests {
252 | t.Run(tt.name, func(t *testing.T) {
253 | mockAPI := new(MockPortainerAPI)
254 | mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
255 |
256 | client := &PortainerClient{cli: mockAPI}
257 |
258 | err := client.UpdateEnvironmentGroupTags(tt.groupID, tt.tagIds)
259 |
260 | if tt.expectedError {
261 | assert.Error(t, err)
262 | return
263 | }
264 | assert.NoError(t, err)
265 | mockAPI.AssertExpectations(t)
266 | })
267 | }
268 | }
269 |
```
--------------------------------------------------------------------------------
/tests/integration/containers/portainer.go:
--------------------------------------------------------------------------------
```go
1 | package containers
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "fmt"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/docker/docker/api/types/container"
11 | "github.com/docker/go-connections/nat"
12 | "github.com/go-openapi/runtime"
13 | httptransport "github.com/go-openapi/runtime/client"
14 | "github.com/go-openapi/strfmt"
15 | "github.com/portainer/client-api-go/v2/pkg/client"
16 | "github.com/portainer/client-api-go/v2/pkg/client/auth"
17 | "github.com/portainer/client-api-go/v2/pkg/client/users"
18 | "github.com/portainer/client-api-go/v2/pkg/models"
19 | "github.com/portainer/portainer-mcp/internal/mcp"
20 | "github.com/testcontainers/testcontainers-go"
21 | "github.com/testcontainers/testcontainers-go/wait"
22 | )
23 |
24 | const (
25 | defaultPortainerImage = "portainer/portainer-ee:" + mcp.SupportedPortainerVersion
26 | defaultAPIPortTCP = "9443/tcp"
27 | adminPassword = "$2y$05$CiHrhW6R6whDVlu7Wdgl0eccb3rg1NWl/mMiO93vQiRIF1SHNFRsS" // Bcrypt hash of "adminpassword123"
28 | // Timeout for the container to start and be ready to use
29 | startupTimeout = time.Second * 5
30 | )
31 |
32 | // PortainerContainer represents a Portainer container for testing
33 | type PortainerContainer struct {
34 | testcontainers.Container
35 | APIPort nat.Port
36 | APIHost string
37 | apiToken string
38 | }
39 |
40 | // portainerContainerConfig holds the configuration for creating a Portainer container
41 | type portainerContainerConfig struct {
42 | Image string
43 | BindDockerSocket bool
44 | }
45 |
46 | // PortainerContainerOption defines a function type for applying options to Portainer container configuration
47 | type PortainerContainerOption func(*portainerContainerConfig)
48 |
49 | // WithImage sets a custom Portainer image
50 | func WithImage(image string) PortainerContainerOption {
51 | return func(cfg *portainerContainerConfig) {
52 | cfg.Image = image
53 | }
54 | }
55 |
56 | // WithDockerSocketBind configures the container to bind mount the Docker socket
57 | func WithDockerSocketBind(bind bool) PortainerContainerOption {
58 | return func(cfg *portainerContainerConfig) {
59 | cfg.BindDockerSocket = bind
60 | }
61 | }
62 |
63 | // NewPortainerContainer creates and starts a new Portainer container with the specified options
64 | func NewPortainerContainer(ctx context.Context, opts ...PortainerContainerOption) (*PortainerContainer, error) {
65 | // Default configuration
66 | cfg := &portainerContainerConfig{
67 | Image: defaultPortainerImage,
68 | BindDockerSocket: false,
69 | }
70 |
71 | // Apply provided options
72 | for _, opt := range opts {
73 | opt(cfg)
74 | }
75 |
76 | // Container request configuration
77 | req := testcontainers.ContainerRequest{
78 | Image: cfg.Image,
79 | ExposedPorts: []string{defaultAPIPortTCP},
80 | WaitingFor: wait.ForAll(
81 | // Wait for the HTTPS server to start
82 | wait.ForLog("starting HTTPS server").
83 | WithStartupTimeout(startupTimeout),
84 | // Then wait for the API to be responsive
85 | wait.ForHTTP("/api/system/status").
86 | WithTLS(true, nil).
87 | WithAllowInsecure(true).
88 | WithPort(defaultAPIPortTCP).
89 | WithStatusCodeMatcher(
90 | func(status int) bool {
91 | return status == http.StatusOK
92 | },
93 | ).
94 | WithStartupTimeout(startupTimeout),
95 | ),
96 | Cmd: []string{
97 | "--admin-password",
98 | adminPassword,
99 | "--log-level",
100 | "DEBUG",
101 | },
102 | HostConfigModifier: func(hostConfig *container.HostConfig) {
103 | if cfg.BindDockerSocket {
104 | hostConfig.Binds = append(hostConfig.Binds, "/var/run/docker.sock:/var/run/docker.sock")
105 | }
106 | },
107 | }
108 |
109 | // Create and start the container
110 | cntr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
111 | ContainerRequest: req,
112 | Started: true,
113 | })
114 | if err != nil {
115 | return nil, fmt.Errorf("failed to start Portainer container: %w", err)
116 | }
117 |
118 | // Get the host and port mapping
119 | host, err := cntr.Host(ctx)
120 | if err != nil {
121 | cntr.Terminate(ctx) // Clean up if we fail post-start
122 | return nil, fmt.Errorf("failed to get container host: %w", err)
123 | }
124 |
125 | mappedPort, err := cntr.MappedPort(ctx, nat.Port(defaultAPIPortTCP))
126 | if err != nil {
127 | cntr.Terminate(ctx) // Clean up if we fail post-start
128 | return nil, fmt.Errorf("failed to get mapped port: %w", err)
129 | }
130 |
131 | pc := &PortainerContainer{
132 | Container: cntr,
133 | APIPort: mappedPort,
134 | APIHost: host,
135 | }
136 |
137 | // Register API token after successful container start and port mapping
138 | if err := pc.registerAPIToken(); err != nil {
139 | // Attempt to clean up the container if token registration fails
140 | cntr.Terminate(ctx)
141 | return nil, fmt.Errorf("failed to register API token: %w", err)
142 | }
143 |
144 | return pc, nil
145 | }
146 |
147 | // GetAPIBaseURL returns the base URL for the Portainer API
148 | func (pc *PortainerContainer) GetAPIBaseURL() string {
149 | return fmt.Sprintf("https://%s:%s", pc.APIHost, pc.APIPort.Port())
150 | }
151 |
152 | // GetHostAndPort returns the host and port for the Portainer API
153 | func (pc *PortainerContainer) GetHostAndPort() (string, string) {
154 | return pc.APIHost, pc.APIPort.Port()
155 | }
156 |
157 | func (pc *PortainerContainer) GetAPIToken() string {
158 | return pc.apiToken
159 | }
160 |
161 | // registerAPIToken registers an API token for the admin user
162 | func (pc *PortainerContainer) registerAPIToken() error {
163 | transport := httptransport.New(
164 | fmt.Sprintf("%s:%s", pc.APIHost, pc.APIPort.Port()),
165 | "/api",
166 | []string{"https"},
167 | )
168 |
169 | transport.Transport = &http.Transport{
170 | TLSClientConfig: &tls.Config{
171 | InsecureSkipVerify: true,
172 | },
173 | }
174 |
175 | portainerClient := client.New(transport, strfmt.Default)
176 |
177 | username := "admin"
178 | password := "adminpassword123"
179 | params := auth.NewAuthenticateUserParams().WithBody(&models.AuthAuthenticatePayload{
180 | Username: &username,
181 | Password: &password,
182 | })
183 |
184 | authResp, err := portainerClient.Auth.AuthenticateUser(params)
185 | if err != nil {
186 | return fmt.Errorf("failed to authenticate user: %w", err)
187 | }
188 |
189 | token := authResp.Payload.Jwt
190 |
191 | // Setup JWT authentication
192 | jwtAuth := runtime.ClientAuthInfoWriterFunc(func(r runtime.ClientRequest, _ strfmt.Registry) error {
193 | return r.SetHeaderParam("Authorization", fmt.Sprintf("Bearer %s", token))
194 | })
195 | transport.DefaultAuthentication = jwtAuth
196 |
197 | description := "test-api-key"
198 | createTokenParams := users.NewUserGenerateAPIKeyParams().WithID(1).WithBody(&models.UsersUserAccessTokenCreatePayload{
199 | Description: &description,
200 | Password: &password,
201 | })
202 |
203 | createTokenResp, err := portainerClient.Users.UserGenerateAPIKey(createTokenParams, nil)
204 | if err != nil {
205 | return fmt.Errorf("failed to generate API key: %w", err)
206 | }
207 |
208 | pc.apiToken = createTokenResp.Payload.RawAPIKey
209 |
210 | return nil
211 | }
212 |
```
--------------------------------------------------------------------------------
/tests/integration/stack_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "testing"
7 |
8 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/portainer/portainer-mcp/internal/mcp"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
11 | "github.com/portainer/portainer-mcp/tests/integration/helpers"
12 |
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | const (
18 | testStackName = "test-mcp-stack"
19 | testStackFile = "version: '3'\nservices:\n web:\n image: nginx:latest"
20 | testStackFileUpdated = "version: '3'\nservices:\n web:\n image: nginx:alpine"
21 | testEdgeGroupName = "test-stack-group"
22 | )
23 |
24 | // prepareStackManagementTestEnvironment creates a test environment group needed for stack tests
25 | func prepareStackManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) int {
26 | // First, enable Edge features in Portainer
27 | host, port := env.Portainer.GetHostAndPort()
28 | serverAddr := fmt.Sprintf("%s:%s", host, port)
29 | tunnelAddr := fmt.Sprintf("%s:8000", host)
30 |
31 | err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
32 | require.NoError(t, err, "Failed to update settings to enable Edge features")
33 |
34 | // Create a test environment group for the stack to be associated with
35 | testGroupID, err := env.RawClient.CreateEdgeGroup(testEdgeGroupName, []int64{})
36 | require.NoError(t, err, "Failed to create test environment group via raw client")
37 |
38 | return int(testGroupID)
39 | }
40 |
41 | // TestStackManagement is an integration test suite that verifies the complete
42 | // lifecycle of stack management in Portainer MCP. It tests stack creation,
43 | // retrieval, file content retrieval, and updates.
44 | func TestStackManagement(t *testing.T) {
45 | env := helpers.NewTestEnv(t)
46 | defer env.Cleanup(t)
47 |
48 | // Prepare the test environment
49 | testGroupID := prepareStackManagementTestEnvironment(t, env)
50 |
51 | var testStackID int
52 |
53 | // Subtest: Stack Creation
54 | // Verifies that:
55 | // - A new stack can be created via the MCP handler
56 | // - The handler response indicates success with an ID
57 | // - The created stack exists in Portainer when checked directly via Raw Client
58 | t.Run("Stack Creation", func(t *testing.T) {
59 | handler := env.MCPServer.HandleCreateStack()
60 | request := mcp.CreateMCPRequest(map[string]any{
61 | "name": testStackName,
62 | "file": testStackFile,
63 | "environmentGroupIds": []any{float64(testGroupID)},
64 | })
65 |
66 | result, err := handler(env.Ctx, request)
67 | require.NoError(t, err, "Failed to create stack via MCP handler")
68 |
69 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
70 | require.True(t, ok, "Expected text content in MCP response")
71 |
72 | // Check for success message and extract ID for later tests
73 | assert.Contains(t, textContent.Text, "Stack created successfully with ID:", "Success message prefix mismatch")
74 |
75 | // Verify by fetching stacks directly via client and finding the created stack by name
76 | stack, err := env.RawClient.GetEdgeStackByName(testStackName)
77 | require.NoError(t, err, "Failed to get stack directly via client after creation")
78 | assert.Equal(t, testStackName, stack.Name, "Stack name mismatch")
79 |
80 | // Extract stack ID for subsequent tests
81 | testStackID = int(stack.ID)
82 | })
83 |
84 | // Subtest: Stack Listing
85 | // Verifies that:
86 | // - The stack list can be retrieved via the MCP handler
87 | // - The list contains the expected stack
88 | // - The stack data matches the expected properties
89 | t.Run("Stack Listing", func(t *testing.T) {
90 | handler := env.MCPServer.HandleGetStacks()
91 | result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
92 | require.NoError(t, err, "Failed to get stacks via MCP handler")
93 |
94 | assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
95 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
96 | assert.True(t, ok, "Expected text content in MCP response")
97 |
98 | var retrievedStacks []models.Stack
99 | err = json.Unmarshal([]byte(textContent.Text), &retrievedStacks)
100 | require.NoError(t, err, "Failed to unmarshal retrieved stacks")
101 | require.Len(t, retrievedStacks, 1, "Expected exactly one stack after unmarshalling")
102 |
103 | stack := retrievedStacks[0]
104 | assert.Equal(t, testStackName, stack.Name, "Stack name mismatch")
105 |
106 | // Fetch the same stack directly via the client
107 | rawStack, err := env.RawClient.GetEdgeStack(int64(testStackID))
108 | require.NoError(t, err, "Failed to get stack directly via client")
109 |
110 | // Convert the raw stack to the expected Stack model
111 | expectedStack := models.ConvertEdgeStackToStack(rawStack)
112 | assert.Equal(t, expectedStack, stack, "Stack mismatch between MCP handler and direct client call")
113 | })
114 |
115 | // Subtest: Get Stack File
116 | // Verifies that:
117 | // - The stack file can be retrieved via the MCP handler
118 | // - The file content matches the content used during creation
119 | t.Run("Get Stack File", func(t *testing.T) {
120 | handler := env.MCPServer.HandleGetStackFile()
121 | request := mcp.CreateMCPRequest(map[string]any{
122 | "id": float64(testStackID),
123 | })
124 |
125 | result, err := handler(env.Ctx, request)
126 | require.NoError(t, err, "Failed to get stack file via MCP handler")
127 |
128 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
129 | require.True(t, ok, "Expected text content in MCP response")
130 |
131 | // Compare with the original content
132 | assert.Equal(t, testStackFile, textContent.Text, "Stack file content mismatch")
133 | })
134 |
135 | // Subtest: Stack Update
136 | // Verifies that:
137 | // - A stack can be updated via the MCP handler
138 | // - The handler response indicates success
139 | // - The stack file is updated when checked directly via Raw Client
140 | t.Run("Stack Update", func(t *testing.T) {
141 | handler := env.MCPServer.HandleUpdateStack()
142 | request := mcp.CreateMCPRequest(map[string]any{
143 | "id": float64(testStackID),
144 | "file": testStackFileUpdated,
145 | "environmentGroupIds": []any{float64(testGroupID)},
146 | })
147 |
148 | result, err := handler(env.Ctx, request)
149 | require.NoError(t, err, "Failed to update stack via MCP handler")
150 |
151 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
152 | require.True(t, ok, "Expected text content in MCP response")
153 | assert.Contains(t, textContent.Text, "Stack updated successfully", "Success message mismatch")
154 |
155 | // Verify by fetching stack file directly via raw client
156 | updatedFile, err := env.RawClient.GetEdgeStackFile(int64(testStackID))
157 | require.NoError(t, err, "Failed to get stack file via raw client after update")
158 | assert.Equal(t, testStackFileUpdated, updatedFile, "Stack file was not updated correctly")
159 | })
160 | }
161 |
```
--------------------------------------------------------------------------------
/internal/mcp/access_group.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/portainer/portainer-mcp/pkg/toolgen"
11 | )
12 |
13 | func (s *PortainerMCPServer) AddAccessGroupFeatures() {
14 | s.addToolIfExists(ToolListAccessGroups, s.HandleGetAccessGroups())
15 |
16 | if !s.readOnly {
17 | s.addToolIfExists(ToolCreateAccessGroup, s.HandleCreateAccessGroup())
18 | s.addToolIfExists(ToolUpdateAccessGroupName, s.HandleUpdateAccessGroupName())
19 | s.addToolIfExists(ToolUpdateAccessGroupUserAccesses, s.HandleUpdateAccessGroupUserAccesses())
20 | s.addToolIfExists(ToolUpdateAccessGroupTeamAccesses, s.HandleUpdateAccessGroupTeamAccesses())
21 | s.addToolIfExists(ToolAddEnvironmentToAccessGroup, s.HandleAddEnvironmentToAccessGroup())
22 | s.addToolIfExists(ToolRemoveEnvironmentFromAccessGroup, s.HandleRemoveEnvironmentFromAccessGroup())
23 | }
24 | }
25 |
26 | func (s *PortainerMCPServer) HandleGetAccessGroups() server.ToolHandlerFunc {
27 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
28 | accessGroups, err := s.cli.GetAccessGroups()
29 | if err != nil {
30 | return mcp.NewToolResultErrorFromErr("failed to get access groups", err), nil
31 | }
32 |
33 | data, err := json.Marshal(accessGroups)
34 | if err != nil {
35 | return mcp.NewToolResultErrorFromErr("failed to marshal access groups", err), nil
36 | }
37 |
38 | return mcp.NewToolResultText(string(data)), nil
39 | }
40 | }
41 |
42 | func (s *PortainerMCPServer) HandleCreateAccessGroup() server.ToolHandlerFunc {
43 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
44 | parser := toolgen.NewParameterParser(request)
45 |
46 | name, err := parser.GetString("name", true)
47 | if err != nil {
48 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
49 | }
50 |
51 | environmentIds, err := parser.GetArrayOfIntegers("environmentIds", false)
52 | if err != nil {
53 | return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
54 | }
55 |
56 | groupID, err := s.cli.CreateAccessGroup(name, environmentIds)
57 | if err != nil {
58 | return mcp.NewToolResultErrorFromErr("failed to create access group", err), nil
59 | }
60 |
61 | return mcp.NewToolResultText(fmt.Sprintf("Access group created successfully with ID: %d", groupID)), nil
62 | }
63 | }
64 |
65 | func (s *PortainerMCPServer) HandleUpdateAccessGroupName() server.ToolHandlerFunc {
66 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
67 | parser := toolgen.NewParameterParser(request)
68 |
69 | id, err := parser.GetInt("id", true)
70 | if err != nil {
71 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
72 | }
73 |
74 | name, err := parser.GetString("name", true)
75 | if err != nil {
76 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
77 | }
78 |
79 | err = s.cli.UpdateAccessGroupName(id, name)
80 | if err != nil {
81 | return mcp.NewToolResultErrorFromErr("failed to update access group name", err), nil
82 | }
83 |
84 | return mcp.NewToolResultText("Access group name updated successfully"), nil
85 | }
86 | }
87 |
88 | func (s *PortainerMCPServer) HandleUpdateAccessGroupUserAccesses() server.ToolHandlerFunc {
89 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
90 | parser := toolgen.NewParameterParser(request)
91 |
92 | id, err := parser.GetInt("id", true)
93 | if err != nil {
94 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
95 | }
96 |
97 | userAccesses, err := parser.GetArrayOfObjects("userAccesses", true)
98 | if err != nil {
99 | return mcp.NewToolResultErrorFromErr("invalid userAccesses parameter", err), nil
100 | }
101 |
102 | userAccessesMap, err := parseAccessMap(userAccesses)
103 | if err != nil {
104 | return mcp.NewToolResultErrorFromErr("invalid user accesses", err), nil
105 | }
106 |
107 | err = s.cli.UpdateAccessGroupUserAccesses(id, userAccessesMap)
108 | if err != nil {
109 | return mcp.NewToolResultErrorFromErr("failed to update access group user accesses", err), nil
110 | }
111 |
112 | return mcp.NewToolResultText("Access group user accesses updated successfully"), nil
113 | }
114 | }
115 |
116 | func (s *PortainerMCPServer) HandleUpdateAccessGroupTeamAccesses() server.ToolHandlerFunc {
117 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
118 | parser := toolgen.NewParameterParser(request)
119 |
120 | id, err := parser.GetInt("id", true)
121 | if err != nil {
122 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
123 | }
124 |
125 | teamAccesses, err := parser.GetArrayOfObjects("teamAccesses", true)
126 | if err != nil {
127 | return mcp.NewToolResultErrorFromErr("invalid teamAccesses parameter", err), nil
128 | }
129 |
130 | teamAccessesMap, err := parseAccessMap(teamAccesses)
131 | if err != nil {
132 | return mcp.NewToolResultErrorFromErr("invalid team accesses", err), nil
133 | }
134 |
135 | err = s.cli.UpdateAccessGroupTeamAccesses(id, teamAccessesMap)
136 | if err != nil {
137 | return mcp.NewToolResultErrorFromErr("failed to update access group team accesses", err), nil
138 | }
139 |
140 | return mcp.NewToolResultText("Access group team accesses updated successfully"), nil
141 | }
142 | }
143 |
144 | func (s *PortainerMCPServer) HandleAddEnvironmentToAccessGroup() server.ToolHandlerFunc {
145 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
146 | parser := toolgen.NewParameterParser(request)
147 |
148 | id, err := parser.GetInt("id", true)
149 | if err != nil {
150 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
151 | }
152 |
153 | environmentId, err := parser.GetInt("environmentId", true)
154 | if err != nil {
155 | return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
156 | }
157 |
158 | err = s.cli.AddEnvironmentToAccessGroup(id, environmentId)
159 | if err != nil {
160 | return mcp.NewToolResultErrorFromErr("failed to add environment to access group", err), nil
161 | }
162 |
163 | return mcp.NewToolResultText("Environment added to access group successfully"), nil
164 | }
165 | }
166 |
167 | func (s *PortainerMCPServer) HandleRemoveEnvironmentFromAccessGroup() server.ToolHandlerFunc {
168 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
169 | parser := toolgen.NewParameterParser(request)
170 |
171 | id, err := parser.GetInt("id", true)
172 | if err != nil {
173 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
174 | }
175 |
176 | environmentId, err := parser.GetInt("environmentId", true)
177 | if err != nil {
178 | return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
179 | }
180 |
181 | err = s.cli.RemoveEnvironmentFromAccessGroup(id, environmentId)
182 | if err != nil {
183 | return mcp.NewToolResultErrorFromErr("failed to remove environment from access group", err), nil
184 | }
185 |
186 | return mcp.NewToolResultText("Environment removed from access group successfully"), nil
187 | }
188 | }
189 |
```
--------------------------------------------------------------------------------
/internal/mcp/server.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/client"
11 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
12 | "github.com/portainer/portainer-mcp/pkg/toolgen"
13 | )
14 |
15 | const (
16 | // MinimumToolsVersion is the minimum supported version of the tools.yaml file
17 | MinimumToolsVersion = "1.0"
18 | // SupportedPortainerVersion is the version of Portainer that is supported by this tool
19 | SupportedPortainerVersion = "2.31.2"
20 | )
21 |
22 | // PortainerClient defines the interface for the wrapper client used by the MCP server
23 | type PortainerClient interface {
24 | // Tag methods
25 | GetEnvironmentTags() ([]models.EnvironmentTag, error)
26 | CreateEnvironmentTag(name string) (int, error)
27 |
28 | // Environment methods
29 | GetEnvironments() ([]models.Environment, error)
30 | UpdateEnvironmentTags(id int, tagIds []int) error
31 | UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error
32 | UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error
33 |
34 | // Environment Group methods
35 | GetEnvironmentGroups() ([]models.Group, error)
36 | CreateEnvironmentGroup(name string, environmentIds []int) (int, error)
37 | UpdateEnvironmentGroupName(id int, name string) error
38 | UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error
39 | UpdateEnvironmentGroupTags(id int, tagIds []int) error
40 |
41 | // Access Group methods
42 | GetAccessGroups() ([]models.AccessGroup, error)
43 | CreateAccessGroup(name string, environmentIds []int) (int, error)
44 | UpdateAccessGroupName(id int, name string) error
45 | UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error
46 | UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error
47 | AddEnvironmentToAccessGroup(id int, environmentId int) error
48 | RemoveEnvironmentFromAccessGroup(id int, environmentId int) error
49 |
50 | // Stack methods
51 | GetStacks() ([]models.Stack, error)
52 | GetStackFile(id int) (string, error)
53 | CreateStack(name string, file string, environmentGroupIds []int) (int, error)
54 | UpdateStack(id int, file string, environmentGroupIds []int) error
55 |
56 | // Team methods
57 | CreateTeam(name string) (int, error)
58 | GetTeams() ([]models.Team, error)
59 | UpdateTeamName(id int, name string) error
60 | UpdateTeamMembers(id int, userIds []int) error
61 |
62 | // User methods
63 | GetUsers() ([]models.User, error)
64 | UpdateUserRole(id int, role string) error
65 |
66 | // Settings methods
67 | GetSettings() (models.PortainerSettings, error)
68 |
69 | // Version methods
70 | GetVersion() (string, error)
71 |
72 | // Docker Proxy methods
73 | ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error)
74 |
75 | // Kubernetes Proxy methods
76 | ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error)
77 | }
78 |
79 | // PortainerMCPServer is the main server that handles MCP protocol communication
80 | // with AI assistants and translates them into Portainer API calls.
81 | type PortainerMCPServer struct {
82 | srv *server.MCPServer
83 | cli PortainerClient
84 | tools map[string]mcp.Tool
85 | readOnly bool
86 | }
87 |
88 | // ServerOption is a function that configures the server
89 | type ServerOption func(*serverOptions)
90 |
91 | // serverOptions contains all configurable options for the server
92 | type serverOptions struct {
93 | client PortainerClient
94 | readOnly bool
95 | disableVersionCheck bool
96 | }
97 |
98 | // WithClient sets a custom client for the server.
99 | // This is primarily used for testing to inject mock clients.
100 | func WithClient(client PortainerClient) ServerOption {
101 | return func(opts *serverOptions) {
102 | opts.client = client
103 | }
104 | }
105 |
106 | // WithReadOnly sets the server to read-only mode.
107 | // This will prevent the server from registering write tools.
108 | func WithReadOnly(readOnly bool) ServerOption {
109 | return func(opts *serverOptions) {
110 | opts.readOnly = readOnly
111 | }
112 | }
113 |
114 | // WithDisableVersionCheck disables the Portainer server version check.
115 | // This allows connecting to unsupported Portainer versions.
116 | func WithDisableVersionCheck(disable bool) ServerOption {
117 | return func(opts *serverOptions) {
118 | opts.disableVersionCheck = disable
119 | }
120 | }
121 |
122 | // NewPortainerMCPServer creates a new Portainer MCP server.
123 | //
124 | // This server provides an implementation of the MCP protocol for Portainer,
125 | // allowing AI assistants to interact with Portainer through a structured API.
126 | //
127 | // Parameters:
128 | // - serverURL: The base URL of the Portainer server (e.g., "https://portainer.example.com")
129 | // - token: The API token for authenticating with the Portainer server
130 | // - toolsPath: Path to the tools.yaml file that defines the available MCP tools
131 | // - options: Optional functional options for customizing server behavior (e.g., WithClient)
132 | //
133 | // Returns:
134 | // - A configured PortainerMCPServer instance ready to be started
135 | // - An error if initialization fails
136 | //
137 | // Possible errors:
138 | // - Failed to load tools from the specified path
139 | // - Failed to communicate with the Portainer server
140 | // - Incompatible Portainer server version
141 | func NewPortainerMCPServer(serverURL, token, toolsPath string, options ...ServerOption) (*PortainerMCPServer, error) {
142 | opts := &serverOptions{}
143 |
144 | for _, option := range options {
145 | option(opts)
146 | }
147 |
148 | tools, err := toolgen.LoadToolsFromYAML(toolsPath, MinimumToolsVersion)
149 | if err != nil {
150 | return nil, fmt.Errorf("failed to load tools: %w", err)
151 | }
152 |
153 | var portainerClient PortainerClient
154 | if opts.client != nil {
155 | portainerClient = opts.client
156 | } else {
157 | portainerClient = client.NewPortainerClient(serverURL, token, client.WithSkipTLSVerify(true))
158 | }
159 |
160 | if !opts.disableVersionCheck {
161 | version, err := portainerClient.GetVersion()
162 | if err != nil {
163 | return nil, fmt.Errorf("failed to get Portainer server version: %w", err)
164 | }
165 |
166 | if version != SupportedPortainerVersion {
167 | return nil, fmt.Errorf("unsupported Portainer server version: %s, only version %s is supported", version, SupportedPortainerVersion)
168 | }
169 | }
170 |
171 | return &PortainerMCPServer{
172 | srv: server.NewMCPServer(
173 | "Portainer MCP Server",
174 | "0.5.1",
175 | server.WithToolCapabilities(true),
176 | server.WithLogging(),
177 | ),
178 | cli: portainerClient,
179 | tools: tools,
180 | readOnly: opts.readOnly,
181 | }, nil
182 | }
183 |
184 | // Start begins listening for MCP protocol messages on standard input/output.
185 | // This is a blocking call that will run until the connection is closed.
186 | func (s *PortainerMCPServer) Start() error {
187 | return server.ServeStdio(s.srv)
188 | }
189 |
190 | // addToolIfExists adds a tool to the server if it exists in the tools map
191 | func (s *PortainerMCPServer) addToolIfExists(toolName string, handler server.ToolHandlerFunc) {
192 | if tool, exists := s.tools[toolName]; exists {
193 | s.srv.AddTool(tool, handler)
194 | } else {
195 | log.Printf("Tool %s not found, will not be registered for MCP usage", toolName)
196 | }
197 | }
198 |
```
--------------------------------------------------------------------------------
/docs/clients_and_models.md:
--------------------------------------------------------------------------------
```markdown
1 | # Portainer MCP Client and Model Usage Guide
2 |
3 | This document clarifies the different client implementations and model structures used within the `portainer-mcp` project to prevent confusion and aid development.
4 |
5 | ## Overview
6 |
7 | The project interacts with the Portainer API using two main client layers and involves two primary sets of data models:
8 |
9 | 1. **Raw Client & Models:** Provided by the `portainer/client-api-go` library.
10 | 2. **Wrapper Client & Local Models:** Defined within `portainer-mcp/pkg/portainer/`.
11 |
12 | Understanding the distinction and interaction between these layers is crucial.
13 |
14 | ## Clients
15 |
16 | ### 1. Raw Client (`portainer/client-api-go/v2`)
17 |
18 | * **Package:** `github.com/portainer/client-api-go/v2`
19 | * **Role:** This is the underlying library that directly communicates with the Portainer API.
20 | * **Usage:** It's instantiated within the Wrapper Client. It's also often used directly within **integration tests** (`tests/integration/`) to fetch the ground-truth state from Portainer for comparison against the MCP handler's output.
21 | * **Models Used:** Interacts primarily with the Raw Models defined in `github.com/portainer/client-api-go/v2/pkg/models`.
22 |
23 | ### 2. Wrapper Client (`portainer-mcp/pkg/portainer/client`)
24 |
25 | * **Package:** `github.com/portainer/portainer-mcp/pkg/portainer/client`
26 | * **Role:** This client acts as an **abstraction layer** on top of the Raw Client. Its primary purposes are:
27 | * To simplify the interface exposed to the rest of the `portainer-mcp` application (specifically the MCP server handlers in `internal/mcp/`).
28 | * To perform necessary **data transformations**, converting Raw Models from the API into the simpler, tailored Local Models.
29 | * To encapsulate common logic or error handling related to Portainer API interactions.
30 | * **Usage:** This is the client used by the **MCP server handlers** (`internal/mcp/server.go` instantiates it and passes it to handlers).
31 | * **Models Used:** Takes Raw Models as input from the Raw Client but typically **returns Local Models** (`portainer-mcp/pkg/portainer/models`) after performing conversions.
32 |
33 | ## Models
34 |
35 | ### 1. Raw Models (`portainer/client-api-go/v2/pkg/models`)
36 |
37 | * **Package:** `github.com/portainer/client-api-go/v2/pkg/models`
38 | * **Role:** These structs directly map to the data structures returned by the Portainer API.
39 | * **Characteristics:** Can be complex, may contain fields not relevant to MCP, and might use types (like numeric enums) that are less convenient for MCP's purposes.
40 | * **Examples:** `models.PortainereeSettings`, `models.PortainereeEndpoint`.
41 | * **Usage:** Returned by the Raw Client, used as input to the conversion functions within the Wrapper Client / Local Models package.
42 | * **Naming Convention:** To improve clarity, variables holding instances of these Raw Models are typically prefixed with `raw` (e.g., `rawSettings`, `rawEndpoint`).
43 |
44 | ### 2. Local Models (`portainer-mcp/pkg/portainer/models`)
45 |
46 | * **Package:** `github.com/portainer/portainer-mcp/pkg/portainer/models`
47 | * **Role:** These are simplified, tailored structs designed specifically for use within the `portainer-mcp` application and for exposure via the MCP tools.
48 | * **Characteristics:** Simpler structure, contain only relevant fields, often use more convenient types (like string enums).
49 | * **Examples:** `models.PortainerSettings`, `models.Environment`, `models.EnvironmentTag`.
50 | * **Usage:** Returned by the Wrapper Client, used within MCP server handlers, and ultimately determine the structure of data returned by MCP tools.
51 |
52 | ### 3. Conversion Functions
53 |
54 | * **Location:** Typically reside within `portainer-mcp/pkg/portainer/models`.
55 | * **Role:** Bridge the gap between Raw Models and Local Models.
56 | * **Examples:** `ConvertSettingsToPortainerSettings`, `ConvertEndpointToEnvironment`.
57 | * **Usage:** Called by the Wrapper Client methods to transform data before returning it. The function parameters accepting Raw Models typically follow the `raw` prefix naming convention (e.g., `func ConvertSettingsToPortainerSettings(rawSettings *apimodels.PortainereeSettings)`).
58 |
59 | ## Typical Workflow Example (`GetSettings`)
60 |
61 | 1. **MCP Handler (`internal/mcp/settings.go`)**: Receives a tool call.
62 | 2. Calls `s.cli.GetSettings()`. Here, `s.cli` is an instance of the **Wrapper Client** (`PortainerClient`).
63 | 3. **Wrapper Client (`pkg/portainer/client/settings.go`)**: Its `GetSettings` method is executed.
64 | 4. Calls the **Raw Client**'s `GetSettings` method (e.g., `c.cli.GetSettings()`).
65 | 5. Raw Client interacts with the Portainer API and returns a **Raw Model** (`*portainermodels.PortainereeSettings`).
66 | 6. Wrapper Client calls the **Conversion Function** (`models.ConvertSettingsToPortainerSettings`) with the Raw Model.
67 | 7. Conversion Function returns a **Local Model** (`models.PortainerSettings`).
68 | 8. Wrapper Client returns the Local Model to the MCP Handler.
69 | 9. MCP Handler marshals the **Local Model** (`models.PortainerSettings`) into JSON and returns it as the tool result.
70 |
71 | ## Import Conventions
72 |
73 | To improve clarity, especially in files where both model types might appear (like tests), consider using consistent import aliases. Leaving the local `portainer-mcp/pkg/portainer/models` package as the default `models` and aliasing the external library is recommended:
74 |
75 | ```go
76 | import (
77 | "github.com/portainer/portainer-mcp/pkg/portainer/models" // Default: models (Local MCP Models)
78 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" // Alias: apimodels (Raw Client-API-Go Models)
79 | )
80 | ```
81 |
82 | This approach keeps code cleaner for the more frequently used local models while clearly indicating when the raw API models are involved.
83 |
84 | ## Testing Implications
85 |
86 | * **Unit Tests** (like `pkg/portainer/client/settings_test.go`): Should mock the Raw Client interface and verify that the Wrapper Client correctly calls the Raw Client and performs the necessary conversions, returning the expected Local Model.
87 | * **Integration Tests** (like `tests/integration/settings_test.go`):
88 | * Call the MCP handler, which uses the Wrapper Client internally and returns JSON representing a Local Model.
89 | * Often need to *also* call the Raw Client directly to get the ground-truth state from the live Portainer instance (variables holding this state should follow the `raw` prefix convention, e.g., `rawEndpoint`).
90 | * May need to manually apply the same Conversion Function to the Raw Model obtained from the Raw Client to create an expected Local Model for comparison against the handler's result.
91 |
92 | By understanding these distinct layers and their interactions, development and testing within `portainer-mcp` should be clearer.
```