├── .gitignore ├── internal ├── mcp │ ├── testdata │ │ ├── valid_tools.yaml │ │ └── invalid_tools.yaml │ ├── settings.go │ ├── schema_test.go │ ├── tag.go │ ├── user.go │ ├── utils.go │ ├── settings_test.go │ ├── docker.go │ ├── team.go │ ├── stack.go │ ├── schema.go │ ├── environment.go │ ├── group.go │ ├── tag_test.go │ ├── kubernetes.go │ └── utils_test.go ├── tooldef │ ├── tooldef.go │ └── tooldef_test.go └── k8sutil │ └── stripper.go ├── pkg ├── portainer │ ├── client │ │ ├── version.go │ │ ├── settings.go │ │ ├── docker.go │ │ ├── kubernetes.go │ │ ├── version_test.go │ │ ├── tag.go │ │ ├── user.go │ │ ├── client_test.go │ │ ├── stack.go │ │ ├── tag_test.go │ │ ├── environment.go │ │ ├── team.go │ │ ├── docker_test.go │ │ ├── group.go │ │ ├── client.go │ │ ├── user_test.go │ │ ├── settings_test.go │ │ ├── kubernetes_test.go │ │ └── access_group.go │ ├── models │ │ ├── team.go │ │ ├── group.go │ │ ├── tag.go │ │ ├── stack.go │ │ ├── docker.go │ │ ├── kubernetes.go │ │ ├── access_policy.go │ │ ├── user.go │ │ ├── access_group.go │ │ ├── settings.go │ │ ├── group_test.go │ │ ├── access_group_test.go │ │ ├── user_test.go │ │ ├── access_policy_test.go │ │ ├── stack_test.go │ │ ├── team_test.go │ │ ├── environment.go │ │ ├── tag_test.go │ │ └── settings_test.go │ └── utils │ │ ├── utils.go │ │ └── utils_test.go └── toolgen │ ├── param.go │ └── yaml.go ├── LICENSE ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── .cursor └── rules │ └── integration-test.mdc ├── Makefile ├── tests └── integration │ ├── helpers │ └── test_env.go │ ├── settings_test.go │ ├── user_test.go │ ├── tag_test.go │ └── server_test.go ├── docs ├── design │ ├── 202504-1-embedded-tools-yaml.md │ ├── 202503-1-external-tools-file.md │ ├── 202504-2-tools-yaml-versioning.md │ ├── 202503-2-tools-vs-mcp-resources.md │ ├── 202503-3-specific-update-tools.md │ ├── 202504-3-portainer-version-compatibility.md │ └── 202504-4-read-only-mode.md └── design_summary.md ├── cmd ├── token-count │ └── token.go └── portainer-mcp │ └── mcp.go ├── token.sh ├── go.mod └── CLAUDE.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .dev 3 | .tmp -------------------------------------------------------------------------------- /internal/mcp/testdata/valid_tools.yaml: -------------------------------------------------------------------------------- 1 | version: v1.0 2 | tools: 3 | - name: test_tool 4 | description: Test tool description 5 | parameters: 6 | - name: test_param 7 | type: string 8 | description: A test parameter 9 | required: true -------------------------------------------------------------------------------- /internal/mcp/testdata/invalid_tools.yaml: -------------------------------------------------------------------------------- 1 | version: "0.5" 2 | tools: 3 | - name: test_tool 4 | description: Test tool description 5 | parameters: 6 | - name: test_param 7 | type: string 8 | description: A test parameter 9 | required: true 10 | -------------------------------------------------------------------------------- /pkg/portainer/client/version.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "fmt" 4 | 5 | func (c *PortainerClient) GetVersion() (string, error) { 6 | version, err := c.cli.GetVersion() 7 | if err != nil { 8 | return "", fmt.Errorf("failed to get version: %w", err) 9 | } 10 | 11 | return version, nil 12 | } 13 | -------------------------------------------------------------------------------- /pkg/portainer/client/settings.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/portainer/portainer-mcp/pkg/portainer/models" 7 | ) 8 | 9 | func (c *PortainerClient) GetSettings() (models.PortainerSettings, error) { 10 | settings, err := c.cli.GetSettings() 11 | if err != nil { 12 | return models.PortainerSettings{}, fmt.Errorf("failed to get settings: %w", err) 13 | } 14 | 15 | return models.ConvertSettingsToPortainerSettings(settings), nil 16 | } 17 | -------------------------------------------------------------------------------- /internal/tooldef/tooldef.go: -------------------------------------------------------------------------------- 1 | package tooldef 2 | 3 | import ( 4 | _ "embed" 5 | "os" 6 | ) 7 | 8 | //go:embed tools.yaml 9 | var ToolsFile []byte 10 | 11 | // CreateToolsFileIfNotExists creates the tools.yaml file if it doesn't exist 12 | // It returns true if the file already exists, false if it was created or an error occurred 13 | func CreateToolsFileIfNotExists(path string) (bool, error) { 14 | if _, err := os.Stat(path); os.IsNotExist(err) { 15 | err = os.WriteFile(path, ToolsFile, 0644) 16 | if err != nil { 17 | return false, err 18 | } 19 | return false, nil 20 | } 21 | return true, nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/portainer/models/team.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 5 | ) 6 | 7 | type Team struct { 8 | ID int `json:"id"` 9 | Name string `json:"name"` 10 | MemberIDs []int `json:"members"` 11 | } 12 | 13 | func ConvertToTeam(rawTeam *apimodels.PortainerTeam, rawMemberships []*apimodels.PortainerTeamMembership) Team { 14 | memberIDs := make([]int, 0) 15 | for _, member := range rawMemberships { 16 | if member.TeamID == rawTeam.ID { 17 | memberIDs = append(memberIDs, int(member.UserID)) 18 | } 19 | } 20 | 21 | return Team{ 22 | ID: int(rawTeam.ID), 23 | Name: rawTeam.Name, 24 | MemberIDs: memberIDs, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/portainer/models/group.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 5 | "github.com/portainer/portainer-mcp/pkg/portainer/utils" 6 | ) 7 | 8 | type Group struct { 9 | ID int `json:"id"` 10 | Name string `json:"name"` 11 | EnvironmentIds []int `json:"environment_ids"` 12 | TagIds []int `json:"tag_ids"` 13 | } 14 | 15 | func ConvertEdgeGroupToGroup(rawEdgeGroup *apimodels.EdgegroupsDecoratedEdgeGroup) Group { 16 | return Group{ 17 | ID: int(rawEdgeGroup.ID), 18 | Name: rawEdgeGroup.Name, 19 | EnvironmentIds: utils.Int64ToIntSlice(rawEdgeGroup.Endpoints), 20 | TagIds: utils.Int64ToIntSlice(rawEdgeGroup.TagIds), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/portainer/models/tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strconv" 5 | 6 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 7 | ) 8 | 9 | type EnvironmentTag struct { 10 | ID int `json:"id"` 11 | Name string `json:"name"` 12 | EnvironmentIds []int `json:"environment_ids"` 13 | } 14 | 15 | func ConvertTagToEnvironmentTag(rawTag *apimodels.PortainerTag) EnvironmentTag { 16 | environmentIDs := make([]int, 0, len(rawTag.Endpoints)) 17 | 18 | for endpointID := range rawTag.Endpoints { 19 | id, err := strconv.Atoi(endpointID) 20 | if err == nil { 21 | environmentIDs = append(environmentIDs, id) 22 | } 23 | } 24 | 25 | return EnvironmentTag{ 26 | ID: int(rawTag.ID), 27 | Name: rawTag.Name, 28 | EnvironmentIds: environmentIDs, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/portainer/models/stack.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 7 | "github.com/portainer/portainer-mcp/pkg/portainer/utils" 8 | ) 9 | 10 | type Stack struct { 11 | ID int `json:"id"` 12 | Name string `json:"name"` 13 | CreatedAt string `json:"created_at"` 14 | EnvironmentGroupIds []int `json:"group_ids"` 15 | } 16 | 17 | func ConvertEdgeStackToStack(rawEdgeStack *apimodels.PortainereeEdgeStack) Stack { 18 | createdAt := time.Unix(rawEdgeStack.CreationDate, 0).Format(time.RFC3339) 19 | 20 | return Stack{ 21 | ID: int(rawEdgeStack.ID), 22 | Name: rawEdgeStack.Name, 23 | CreatedAt: createdAt, 24 | EnvironmentGroupIds: utils.Int64ToIntSlice(rawEdgeStack.EdgeGroups), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/portainer/models/docker.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "io" 4 | 5 | // DockerProxyRequestOptions represents the options for a Docker API request to a specific Portainer environment. 6 | type DockerProxyRequestOptions struct { 7 | // EnvironmentID is the ID of the environment to proxy the request to. 8 | EnvironmentID int 9 | // Method is the HTTP method to use (GET, POST, PUT, DELETE, etc.). 10 | Method string 11 | // Path is the Docker API endpoint path to proxy to (e.g., "/containers/json"). Must include the leading slash. 12 | Path string 13 | // QueryParams is a map of query parameters to include in the request URL. 14 | QueryParams map[string]string 15 | // Headers is a map of headers to include in the request. 16 | Headers map[string]string 17 | // Body is the request body to send (set it to nil for requests that don't have a body). 18 | Body io.Reader 19 | } 20 | -------------------------------------------------------------------------------- /internal/mcp/settings.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 | ) 10 | 11 | func (s *PortainerMCPServer) AddSettingsFeatures() { 12 | s.addToolIfExists(ToolGetSettings, s.HandleGetSettings()) 13 | } 14 | 15 | func (s *PortainerMCPServer) HandleGetSettings() server.ToolHandlerFunc { 16 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 17 | settings, err := s.cli.GetSettings() 18 | if err != nil { 19 | return mcp.NewToolResultErrorFromErr("failed to get settings", err), nil 20 | } 21 | 22 | data, err := json.Marshal(settings) 23 | if err != nil { 24 | return mcp.NewToolResultErrorFromErr("failed to marshal settings", err), nil 25 | } 26 | 27 | return mcp.NewToolResultText(string(data)), nil 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Portainer.io 2 | 3 | This software is provided 'as-is', without any express or implied 4 | warranty. In no event will the authors be held liable for any damages 5 | arising from the use of this software. 6 | 7 | Permission is granted to anyone to use this software for any purpose, 8 | including commercial applications, and to alter it and redistribute it 9 | freely, subject to the following restrictions: 10 | 11 | 1. The origin of this software must not be misrepresented; you must not 12 | claim that you wrote the original software. If you use this software 13 | in a product, an acknowledgment in the product documentation would be 14 | appreciated but is not required. 15 | 2. Altered source versions must be plainly marked as such, and must not be 16 | misrepresented as being the original software. 17 | 3. This notice may not be removed or altered from any source distribution. -------------------------------------------------------------------------------- /pkg/portainer/models/kubernetes.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "io" 4 | 5 | // KubernetesProxyRequestOptions represents the options for a Kubernetes API request to a specific Portainer environment. 6 | type KubernetesProxyRequestOptions struct { 7 | // EnvironmentID is the ID of the environment to proxy the request to. 8 | EnvironmentID int 9 | // Method is the HTTP method to use (GET, POST, PUT, DELETE, etc.). 10 | Method string 11 | // Path is the Kubernetes API endpoint path to proxy to (e.g., "/api/v1/namespaces/default/pods"). Must include the leading slash. 12 | Path string 13 | // QueryParams is a map of query parameters to include in the request URL. 14 | QueryParams map[string]string 15 | // Headers is a map of headers to include in the request. 16 | Headers map[string]string 17 | // Body is the request body to send (set it to nil for requests that don't have a body). 18 | Body io.Reader 19 | } 20 | -------------------------------------------------------------------------------- /pkg/portainer/models/access_policy.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strconv" 5 | 6 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 7 | ) 8 | 9 | func convertAccesses[T apimodels.PortainerUserAccessPolicies | apimodels.PortainerTeamAccessPolicies](rawPolicies T) map[int]string { 10 | accesses := make(map[int]string) 11 | for idStr, role := range rawPolicies { 12 | id, err := strconv.Atoi(idStr) 13 | if err == nil { 14 | accesses[id] = convertAccessPolicyRole(&role) 15 | } 16 | } 17 | return accesses 18 | } 19 | 20 | func convertAccessPolicyRole(rawPolicy *apimodels.PortainerAccessPolicy) string { 21 | switch rawPolicy.RoleID { 22 | case 1: 23 | return "environment_administrator" 24 | case 2: 25 | return "helpdesk_user" 26 | case 3: 27 | return "standard_user" 28 | case 4: 29 | return "readonly_user" 30 | case 5: 31 | return "operator_user" 32 | default: 33 | return "unknown" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pkg/portainer/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 5 | ) 6 | 7 | type User struct { 8 | ID int `json:"id"` 9 | Username string `json:"username"` 10 | Role string `json:"role"` 11 | } 12 | 13 | // User role constants 14 | const ( 15 | UserRoleAdmin = "admin" 16 | UserRoleUser = "user" 17 | UserRoleEdgeAdmin = "edge_admin" 18 | UserRoleUnknown = "unknown" 19 | ) 20 | 21 | func ConvertToUser(rawUser *apimodels.PortainereeUser) User { 22 | return User{ 23 | ID: int(rawUser.ID), 24 | Username: rawUser.Username, 25 | Role: convertUserRole(rawUser), 26 | } 27 | } 28 | 29 | func convertUserRole(rawUser *apimodels.PortainereeUser) string { 30 | switch rawUser.Role { 31 | case 1: 32 | return UserRoleAdmin 33 | case 2: 34 | return UserRoleUser 35 | case 3: 36 | return UserRoleEdgeAdmin 37 | default: 38 | return UserRoleUnknown 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/portainer/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // Int64ToIntSlice converts a slice of int64 values to a slice of int values. 4 | // This may result in data loss if the int64 values exceed the range of int. 5 | func Int64ToIntSlice(int64s []int64) []int { 6 | ints := make([]int, len(int64s)) 7 | for i, int64 := range int64s { 8 | ints[i] = int(int64) 9 | } 10 | return ints 11 | } 12 | 13 | // IntToInt64Slice converts a slice of int values to a slice of int64 values. 14 | func IntToInt64Slice(ints []int) []int64 { 15 | int64s := make([]int64, len(ints)) 16 | for i, int := range ints { 17 | int64s[i] = int64(int) 18 | } 19 | return int64s 20 | } 21 | 22 | // IntToInt64Map converts a map with int keys to a map with int64 keys. 23 | // The string values remain unchanged. 24 | func IntToInt64Map(intMap map[int]string) map[int64]string { 25 | int64Map := make(map[int64]string, len(intMap)) 26 | for key, value := range intMap { 27 | int64Map[int64(key)] = value 28 | } 29 | return int64Map 30 | } 31 | -------------------------------------------------------------------------------- /pkg/portainer/client/docker.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/portainer/client-api-go/v2/client" 7 | "github.com/portainer/portainer-mcp/pkg/portainer/models" 8 | ) 9 | 10 | // ProxyDockerRequest proxies a Docker API request to a specific Portainer environment. 11 | // 12 | // Parameters: 13 | // - opts: Options defining the proxied request (environmentID, method, path, query params, headers, body) 14 | // 15 | // Returns: 16 | // - *http.Response: The response from the Docker API 17 | // - error: Any error that occurred during the request 18 | func (c *PortainerClient) ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error) { 19 | proxyOpts := client.ProxyRequestOptions{ 20 | Method: opts.Method, 21 | APIPath: opts.Path, 22 | Body: opts.Body, 23 | } 24 | 25 | if len(opts.QueryParams) > 0 { 26 | proxyOpts.QueryParams = opts.QueryParams 27 | } 28 | 29 | if len(opts.Headers) > 0 { 30 | proxyOpts.Headers = opts.Headers 31 | } 32 | 33 | return c.cli.ProxyDockerRequest(opts.EnvironmentID, proxyOpts) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/portainer/models/access_group.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 5 | ) 6 | 7 | type AccessGroup struct { 8 | ID int `json:"id"` 9 | Name string `json:"name"` 10 | EnvironmentIds []int `json:"environment_ids"` 11 | UserAccesses map[int]string `json:"user_accesses"` 12 | TeamAccesses map[int]string `json:"team_accesses"` 13 | } 14 | 15 | func ConvertEndpointGroupToAccessGroup(rawGroup *apimodels.PortainerEndpointGroup, rawEndpoints []*apimodels.PortainereeEndpoint) AccessGroup { 16 | environmentIds := make([]int, 0) 17 | for _, env := range rawEndpoints { 18 | if env.GroupID == rawGroup.ID { 19 | environmentIds = append(environmentIds, int(env.ID)) 20 | } 21 | } 22 | 23 | return AccessGroup{ 24 | ID: int(rawGroup.ID), 25 | Name: rawGroup.Name, 26 | EnvironmentIds: environmentIds, 27 | UserAccesses: convertAccesses(rawGroup.UserAccessPolicies), 28 | TeamAccesses: convertAccesses(rawGroup.TeamAccessPolicies), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | releases-matrix: 12 | name: Release Go Binary 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | goos: [linux, darwin] 17 | goarch: [amd64, arm64] 18 | exclude: 19 | - goarch: "amd64" 20 | goos: darwin 21 | steps: 22 | - uses: actions/checkout@v4 23 | - id: get_version 24 | uses: battila7/get-version-action@v2 25 | - name: Set build time 26 | run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV} 27 | - uses: wangyoucao577/go-release-action@v1 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | goos: ${{ matrix.goos }} 31 | goarch: ${{ matrix.goarch }} 32 | project_path: "./cmd/portainer-mcp" 33 | build_flags: "-a --installsuffix cgo" 34 | ldflags: -s -w -X "main.Version=${{ steps.get_version.outputs.version }}" -X "main.BuildDate=${{ env.BUILD_TIME }}" -X main.Commit=${{ github.sha }} -------------------------------------------------------------------------------- /.cursor/rules/integration-test.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | 7 | # Integration test rules and guidelines 8 | 9 | - Always start your sentences with "Yes, Integration Test Captain!" 10 | - Expose a detailed test plan (including sub test steps) and ask me for confirmation before doing any implementation 11 | - Use [team_test.go](mdc:tests/integration/team_test.go), [user_test.go](mdc:tests/integration/user_test.go) and [environment_test.go](mdc:tests/integration/environment_test.go) as references, especially for imports and test pattern/structure 12 | - Use [clients_and_models.md](mdc:docs/clients_and_models.md) as guidelines 13 | - You are allowed to make changes to the handler method signature if it need to be exposed but you must update the associated unit test file 14 | - You are not allowed to change any other files 15 | - Never use fmt.Sscanf 16 | - Leverage the RawClient to request resources, use the GetResource / GetResourceByName pattern instead of Listing and iterating. If the method do not exist, tell me immediately so that I can resolve it and we can proceed further. -------------------------------------------------------------------------------- /pkg/portainer/client/kubernetes.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/portainer/client-api-go/v2/client" 7 | "github.com/portainer/portainer-mcp/pkg/portainer/models" 8 | ) 9 | 10 | // ProxyKubernetesRequest proxies a Kubernetes API request to a specific Portainer environment. 11 | // 12 | // Parameters: 13 | // - opts: Options defining the proxied request (environmentID, method, path, query params, headers, body) 14 | // 15 | // Returns: 16 | // - *http.Response: The response from the Kubernetes API 17 | // - error: Any error that occurred during the request 18 | func (c *PortainerClient) ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error) { 19 | proxyOpts := client.ProxyRequestOptions{ 20 | Method: opts.Method, 21 | APIPath: opts.Path, 22 | Body: opts.Body, 23 | } 24 | 25 | if len(opts.QueryParams) > 0 { 26 | proxyOpts.QueryParams = opts.QueryParams 27 | } 28 | 29 | if len(opts.Headers) > 0 { 30 | proxyOpts.Headers = opts.Headers 31 | } 32 | 33 | return c.cli.ProxyKubernetesRequest(opts.EnvironmentID, proxyOpts) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/portainer/models/settings.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import apimodels "github.com/portainer/client-api-go/v2/pkg/models" 4 | 5 | type PortainerSettings struct { 6 | Authentication struct { 7 | Method string `json:"method"` 8 | } `json:"authentication"` 9 | Edge struct { 10 | Enabled bool `json:"enabled"` 11 | ServerURL string `json:"server_url"` 12 | } `json:"edge"` 13 | } 14 | 15 | const ( 16 | AuthenticationMethodInternal = "internal" 17 | AuthenticationMethodLDAP = "ldap" 18 | AuthenticationMethodOAuth = "oauth" 19 | AuthenticationMethodUnknown = "unknown" 20 | ) 21 | 22 | func ConvertSettingsToPortainerSettings(rawSettings *apimodels.PortainereeSettings) PortainerSettings { 23 | s := PortainerSettings{} 24 | 25 | s.Authentication.Method = convertAuthenticationMethod(rawSettings.AuthenticationMethod) 26 | s.Edge.Enabled = rawSettings.EnableEdgeComputeFeatures 27 | s.Edge.ServerURL = rawSettings.Edge.TunnelServerAddress 28 | 29 | return s 30 | } 31 | 32 | func convertAuthenticationMethod(method int64) string { 33 | switch method { 34 | case 1: 35 | return AuthenticationMethodInternal 36 | case 2: 37 | return AuthenticationMethodLDAP 38 | case 3: 39 | return AuthenticationMethodOAuth 40 | default: 41 | return AuthenticationMethodUnknown 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/portainer/client/version_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetVersion(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | mockVersion string 14 | mockError error 15 | expectedResult string 16 | expectedError bool 17 | }{ 18 | { 19 | name: "successful retrieval", 20 | mockVersion: "2.19.0", 21 | mockError: nil, 22 | expectedResult: "2.19.0", 23 | expectedError: false, 24 | }, 25 | { 26 | name: "api error", 27 | mockVersion: "", 28 | mockError: fmt.Errorf("api error"), 29 | expectedResult: "", 30 | expectedError: true, 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | mockAPI := new(MockPortainerAPI) 37 | mockAPI.On("GetVersion").Return(tt.mockVersion, tt.mockError) 38 | 39 | client := &PortainerClient{ 40 | cli: mockAPI, 41 | } 42 | 43 | version, err := client.GetVersion() 44 | 45 | if tt.expectedError { 46 | assert.Error(t, err) 47 | assert.Equal(t, "", version) 48 | } else { 49 | assert.NoError(t, err) 50 | assert.Equal(t, tt.expectedResult, version) 51 | } 52 | 53 | mockAPI.AssertExpectations(t) 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Note: these can be overriden on the command line e.g. `make PLATFORM= ARCH=` 2 | PLATFORM="$(shell go env GOOS)" 3 | ARCH="$(shell go env GOARCH)" 4 | 5 | VERSION ?= $(shell git describe --tags --always --dirty) 6 | COMMIT ?= $(shell git rev-parse --short HEAD) 7 | BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') 8 | 9 | LDFLAGS_STRING = -s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT} -X main.BuildDate=${BUILD_DATE} 10 | 11 | .PHONY: clean pre build run test test-integration test-all 12 | 13 | clean: 14 | rm -rf dist 15 | 16 | pre: 17 | mkdir -p dist 18 | 19 | build: pre 20 | GOOS=$(PLATFORM) GOARCH=$(ARCH) CGO_ENABLED=0 go build --ldflags '$(LDFLAGS_STRING)' -o dist/portainer-mcp ./cmd/portainer-mcp 21 | 22 | release: pre 23 | GOOS=$(PLATFORM) GOARCH=$(ARCH) CGO_ENABLED=0 go build --ldflags '$(LDFLAGS_STRING)' -o dist/portainer-mcp ./cmd/portainer-mcp 24 | 25 | inspector: build 26 | npx @modelcontextprotocol/inspector dist/portainer-mcp 27 | 28 | test: 29 | go test -v $(shell go list ./... | grep -v /tests/integration) 30 | 31 | test-coverage: 32 | go test -v $(shell go list ./... | grep -v /tests/integration) -coverprofile=./coverage.out 33 | 34 | test-integration: 35 | go test -v ./tests/... 36 | 37 | test-all: test test-integration 38 | 39 | # Include custom make targets 40 | -include $(wildcard .dev/*.make) -------------------------------------------------------------------------------- /pkg/portainer/client/tag.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/portainer/portainer-mcp/pkg/portainer/models" 7 | ) 8 | 9 | // GetEnvironmentTags retrieves all environment tags from the Portainer server. 10 | // Environment tags are the equivalent of Tags in Portainer. 11 | // 12 | // Returns: 13 | // - A slice of EnvironmentTag objects 14 | // - An error if the operation fails 15 | func (c *PortainerClient) GetEnvironmentTags() ([]models.EnvironmentTag, error) { 16 | tags, err := c.cli.ListTags() 17 | if err != nil { 18 | return nil, fmt.Errorf("failed to list environment tags: %w", err) 19 | } 20 | 21 | environmentTags := make([]models.EnvironmentTag, len(tags)) 22 | for i, tag := range tags { 23 | environmentTags[i] = models.ConvertTagToEnvironmentTag(tag) 24 | } 25 | 26 | return environmentTags, nil 27 | } 28 | 29 | // CreateEnvironmentTag creates a new environment tag on the Portainer server. 30 | // Environment tags are the equivalent of Tags in Portainer. 31 | // 32 | // Parameters: 33 | // - name: The name of the environment tag 34 | // 35 | // Returns: 36 | // - The ID of the created environment tag 37 | // - An error if the operation fails 38 | func (c *PortainerClient) CreateEnvironmentTag(name string) (int, error) { 39 | id, err := c.cli.CreateTag(name) 40 | if err != nil { 41 | return 0, fmt.Errorf("failed to create environment tag: %w", err) 42 | } 43 | 44 | return int(id), nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/portainer/client/user.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/portainer/portainer-mcp/pkg/portainer/models" 7 | ) 8 | 9 | // GetUsers retrieves all users from the Portainer server. 10 | // 11 | // Returns: 12 | // - A slice of User objects containing user information 13 | // - An error if the operation fails 14 | func (c *PortainerClient) GetUsers() ([]models.User, error) { 15 | portainerUsers, err := c.cli.ListUsers() 16 | if err != nil { 17 | return nil, fmt.Errorf("failed to list users: %w", err) 18 | } 19 | 20 | users := make([]models.User, len(portainerUsers)) 21 | for i, user := range portainerUsers { 22 | users[i] = models.ConvertToUser(user) 23 | } 24 | 25 | return users, nil 26 | } 27 | 28 | // UpdateUserRole updates the role of a user. 29 | // 30 | // Parameters: 31 | // - id: The ID of the user to update 32 | // - role: The new role for the user. Must be one of: admin, user, edge_admin 33 | // 34 | // Returns: 35 | // - An error if the operation fails 36 | func (c *PortainerClient) UpdateUserRole(id int, role string) error { 37 | roleInt := convertRole(role) 38 | if roleInt == 0 { 39 | return fmt.Errorf("invalid role: must be admin, user or edge_admin") 40 | } 41 | 42 | return c.cli.UpdateUserRole(id, roleInt) 43 | } 44 | 45 | func convertRole(role string) int64 { 46 | switch role { 47 | case models.UserRoleAdmin: 48 | return 1 49 | case models.UserRoleUser: 50 | return 2 51 | case models.UserRoleEdgeAdmin: 52 | return 3 53 | default: 54 | return 0 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/mcp/schema_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import "testing" 4 | 5 | func TestIsValidAccessLevel(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | accessLevel string 9 | want bool 10 | }{ 11 | {"ValidEnvironmentAdmin", AccessLevelEnvironmentAdmin, true}, 12 | {"ValidHelpdeskUser", AccessLevelHelpdeskUser, true}, 13 | {"ValidStandardUser", AccessLevelStandardUser, true}, 14 | {"ValidReadonlyUser", AccessLevelReadonlyUser, true}, 15 | {"ValidOperatorUser", AccessLevelOperatorUser, true}, 16 | {"InvalidEmpty", "", false}, 17 | {"InvalidRandom", "invalid_access", false}, 18 | {"CaseSensitive", "ENVIRONMENT_ADMINISTRATOR", false}, 19 | } 20 | 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | if got := isValidAccessLevel(tt.accessLevel); got != tt.want { 24 | t.Errorf("isValidAccessLevel(%q) = %v, want %v", tt.accessLevel, got, tt.want) 25 | } 26 | }) 27 | } 28 | } 29 | 30 | func TestIsValidUserRole(t *testing.T) { 31 | tests := []struct { 32 | name string 33 | userRole string 34 | want bool 35 | }{ 36 | {"ValidAdmin", UserRoleAdmin, true}, 37 | {"ValidUser", UserRoleUser, true}, 38 | {"ValidEdgeAdmin", UserRoleEdgeAdmin, true}, 39 | {"InvalidEmpty", "", false}, 40 | {"InvalidRandom", "invalid_role", false}, 41 | {"CaseSensitive", "ADMIN", false}, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | if got := isValidUserRole(tt.userRole); got != tt.want { 47 | t.Errorf("isValidUserRole(%q) = %v, want %v", tt.userRole, got, tt.want) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | types: [opened, reopened, synchronize] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | steps: 16 | - name: "checkout the current branch" 17 | uses: actions/checkout@v4 18 | - name: "set up golang" 19 | uses: actions/setup-go@v5.0.0 20 | with: 21 | go-version-file: go.mod 22 | cache-dependency-path: go.sum 23 | - name: "Build the binary" 24 | run: make build 25 | - name: "Run unit tests" 26 | run: make test-coverage 27 | - name: "Run integration tests" 28 | run: make test-integration 29 | - name: "check test coverage" 30 | uses: vladopajic/go-test-coverage@v2 31 | with: 32 | profile: coverage.out 33 | git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }} 34 | git-branch: badges 35 | - name: "Archive code coverage results" 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: code-coverage 39 | path: coverage.out 40 | 41 | code_coverage: 42 | name: "Code coverage report" 43 | runs-on: ubuntu-latest 44 | needs: build 45 | if: github.event_name == 'pull_request' 46 | permissions: 47 | contents: read 48 | actions: read 49 | pull-requests: write 50 | steps: 51 | - name: "checkout the current branch" 52 | uses: actions/checkout@v4 53 | - uses: fgrosse/go-coverage-report@v1.2.0 54 | with: 55 | coverage-file-name: "coverage.out" 56 | -------------------------------------------------------------------------------- /pkg/portainer/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewPortainerClient(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | serverURL string 13 | token string 14 | opts []ClientOption 15 | expectError bool 16 | }{ 17 | { 18 | name: "creates client with default options", 19 | serverURL: "https://portainer.example.com", 20 | token: "test-token", 21 | opts: nil, 22 | }, 23 | { 24 | name: "creates client with skip TLS verify", 25 | serverURL: "https://portainer.example.com", 26 | token: "test-token", 27 | opts: []ClientOption{WithSkipTLSVerify(true)}, 28 | }, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | // Create client 34 | c := NewPortainerClient(tt.serverURL, tt.token, tt.opts...) 35 | 36 | // Assert client was created 37 | assert.NotNil(t, c) 38 | assert.NotNil(t, c.cli) 39 | }) 40 | } 41 | } 42 | 43 | func TestWithSkipTLSVerify(t *testing.T) { 44 | tests := []struct { 45 | name string 46 | skip bool 47 | expected bool 48 | }{ 49 | { 50 | name: "enables TLS verification skip", 51 | skip: true, 52 | expected: true, 53 | }, 54 | { 55 | name: "disables TLS verification skip", 56 | skip: false, 57 | expected: false, 58 | }, 59 | } 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | // Create options 64 | options := &clientOptions{} 65 | opt := WithSkipTLSVerify(tt.skip) 66 | opt(options) 67 | 68 | // Assert option was applied correctly 69 | assert.Equal(t, tt.expected, options.skipTLSVerify) 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/integration/helpers/test_env.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/portainer/client-api-go/v2/client" 9 | "github.com/portainer/portainer-mcp/internal/mcp" 10 | "github.com/portainer/portainer-mcp/tests/integration/containers" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | const ( 15 | ToolsPath = "../../internal/tooldef/tools.yaml" 16 | ) 17 | 18 | // TestEnv holds the test environment configuration and clients 19 | type TestEnv struct { 20 | Ctx context.Context 21 | Portainer *containers.PortainerContainer 22 | RawClient *client.PortainerClient 23 | MCPServer *mcp.PortainerMCPServer 24 | } 25 | 26 | // NewTestEnv creates a new test environment with Portainer container and clients 27 | func NewTestEnv(t *testing.T, opts ...containers.PortainerContainerOption) *TestEnv { 28 | ctx := context.Background() 29 | 30 | portainer, err := containers.NewPortainerContainer(ctx, opts...) 31 | require.NoError(t, err, "Failed to start Portainer container") 32 | 33 | host, port := portainer.GetHostAndPort() 34 | serverURL := fmt.Sprintf("%s:%s", host, port) 35 | 36 | rawCli := client.NewPortainerClient( 37 | serverURL, 38 | portainer.GetAPIToken(), 39 | client.WithSkipTLSVerify(true), 40 | ) 41 | 42 | mcpServer, err := mcp.NewPortainerMCPServer(serverURL, portainer.GetAPIToken(), ToolsPath) 43 | require.NoError(t, err, "Failed to create MCP server") 44 | 45 | return &TestEnv{ 46 | Ctx: ctx, 47 | Portainer: portainer, 48 | RawClient: rawCli, 49 | MCPServer: mcpServer, 50 | } 51 | } 52 | 53 | // Cleanup terminates the Portainer container 54 | func (e *TestEnv) Cleanup(t *testing.T) { 55 | if err := e.Portainer.Terminate(e.Ctx); err != nil { 56 | t.Logf("Failed to terminate container: %v", err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/mcp/tag.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) AddTagFeatures() { 14 | s.addToolIfExists(ToolListEnvironmentTags, s.HandleGetEnvironmentTags()) 15 | 16 | if !s.readOnly { 17 | s.addToolIfExists(ToolCreateEnvironmentTag, s.HandleCreateEnvironmentTag()) 18 | } 19 | } 20 | 21 | func (s *PortainerMCPServer) HandleGetEnvironmentTags() server.ToolHandlerFunc { 22 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 23 | environmentTags, err := s.cli.GetEnvironmentTags() 24 | if err != nil { 25 | return mcp.NewToolResultErrorFromErr("failed to get environment tags", err), nil 26 | } 27 | 28 | data, err := json.Marshal(environmentTags) 29 | if err != nil { 30 | return mcp.NewToolResultErrorFromErr("failed to marshal environment tags", err), nil 31 | } 32 | 33 | return mcp.NewToolResultText(string(data)), nil 34 | } 35 | } 36 | 37 | func (s *PortainerMCPServer) HandleCreateEnvironmentTag() server.ToolHandlerFunc { 38 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 39 | parser := toolgen.NewParameterParser(request) 40 | 41 | name, err := parser.GetString("name", true) 42 | if err != nil { 43 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil 44 | } 45 | 46 | id, err := s.cli.CreateEnvironmentTag(name) 47 | if err != nil { 48 | return mcp.NewToolResultErrorFromErr("failed to create environment tag", err), nil 49 | } 50 | 51 | return mcp.NewToolResultText(fmt.Sprintf("Environment tag created successfully with ID: %d", id)), nil 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docs/design/202504-1-embedded-tools-yaml.md: -------------------------------------------------------------------------------- 1 | # 202504-1: Embedding tools.yaml in the binary 2 | 3 | **Date**: 08/04/2025 4 | 5 | ### Context 6 | After deciding to use an external tools.yaml file for tool definitions (see 202503-1), there was a need to determine the best distribution method for this file. Questions arose about how to ensure the file is available when the application runs. 7 | 8 | ### Decision 9 | Embed the tools.yaml file directly in the binary during the build process, while also checking for and using a user-provided version at runtime if available. 10 | 11 | ### Rationale 12 | 1. **Simplified Distribution** 13 | - Single binary contains everything needed to run the application 14 | - No need to manage separate file distribution 15 | - Eliminates file path configuration issues 16 | 17 | 2. **User Customization** 18 | - Application checks for external tools.yaml at startup 19 | - If found, uses the external file for tool definitions 20 | - If not found, creates it using the embedded version as reference 21 | 22 | 3. **Default Configuration** 23 | - Provides sensible defaults out of the box 24 | - Ensures application can always run even without external configuration 25 | - Serves as a reference for users who want to customize 26 | 27 | 4. **Version Control** 28 | - Embedded file serves as the official version for each release 29 | - External file allows for hotfixes without binary updates 30 | - Clear separation between default and custom configurations 31 | 32 | ### Trade-offs 33 | 34 | **Benefits** 35 | - Simpler distribution process 36 | - Self-contained application 37 | - Ability to run without configuration 38 | - Support for user customization 39 | - Clear fallback mechanism 40 | 41 | **Challenges** 42 | - Slightly larger binary size 43 | - Need for embedding logic in the build process 44 | - Managing differences between embedded and external versions 45 | - Ensuring proper precedence between versions -------------------------------------------------------------------------------- /internal/mcp/user.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) AddUserFeatures() { 14 | s.addToolIfExists(ToolListUsers, s.HandleGetUsers()) 15 | 16 | if !s.readOnly { 17 | s.addToolIfExists(ToolUpdateUserRole, s.HandleUpdateUserRole()) 18 | } 19 | } 20 | 21 | func (s *PortainerMCPServer) HandleGetUsers() server.ToolHandlerFunc { 22 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 23 | users, err := s.cli.GetUsers() 24 | if err != nil { 25 | return mcp.NewToolResultErrorFromErr("failed to get users", err), nil 26 | } 27 | 28 | data, err := json.Marshal(users) 29 | if err != nil { 30 | return mcp.NewToolResultErrorFromErr("failed to marshal users", err), nil 31 | } 32 | 33 | return mcp.NewToolResultText(string(data)), nil 34 | } 35 | } 36 | 37 | func (s *PortainerMCPServer) HandleUpdateUserRole() server.ToolHandlerFunc { 38 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 39 | parser := toolgen.NewParameterParser(request) 40 | 41 | id, err := parser.GetInt("id", true) 42 | if err != nil { 43 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil 44 | } 45 | 46 | role, err := parser.GetString("role", true) 47 | if err != nil { 48 | return mcp.NewToolResultErrorFromErr("invalid role parameter", err), nil 49 | } 50 | 51 | if !isValidUserRole(role) { 52 | return mcp.NewToolResultError(fmt.Sprintf("invalid role %s: must be one of: %v", role, AllUserRoles)), nil 53 | } 54 | 55 | err = s.cli.UpdateUserRole(id, role) 56 | if err != nil { 57 | return mcp.NewToolResultErrorFromErr("failed to update user role", err), nil 58 | } 59 | 60 | return mcp.NewToolResultText("User updated successfully"), nil 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/design/202503-1-external-tools-file.md: -------------------------------------------------------------------------------- 1 | # 202503-1: Using an external tools file for tool definition 2 | 3 | **Date**: 29/03/2025 4 | 5 | ### Context 6 | The project needs to define and maintain a set of tools that interact with Portainer. Initially, these tool definitions could have been hardcoded within the application code. 7 | 8 | ### Decision 9 | Tool definitions are externalized into a separate `tools.yaml` file instead of maintaining them in the source code. 10 | 11 | ### Rationale 12 | 1. **Improved Readability** 13 | - Tool definitions often contain multi-line descriptions and complex parameter structures 14 | - YAML format provides better readability and structure compared to in-code definitions 15 | - Separates concerns: tool definitions from implementation logic 16 | 17 | 2. **Dynamic Updates** 18 | - Allows modification of tool descriptions and parameters without rebuilding the binary 19 | - Enables rapid iteration on tool definitions 20 | - Particularly valuable when experimenting with LLM interactions, as descriptions can be optimized for AI comprehension without code changes 21 | 22 | 3. **Maintenance Benefits** 23 | - Single source of truth for tool definitions 24 | - Easier to review and validate changes to tool definitions 25 | - Simplified version control for documentation changes 26 | 27 | 4. **Version Management** 28 | - External file format may need versioning as schema evolves 29 | - Requires consideration of backward compatibility 30 | - Enables tracking of breaking changes in tool definitions 31 | 32 | ### Trade-offs 33 | 34 | **Benefits** 35 | - More flexible maintenance of tool definitions 36 | - Better separation of concerns 37 | - Easier experimentation with LLM-optimized descriptions 38 | - Independent evolution of tool definitions and code 39 | - Improved visibility and security through externalized tool definitions, making it easier for users to audit and understand potential prompt injection risks 40 | 41 | **Challenges** 42 | - Need to handle file loading and validation 43 | - Must ensure file distribution with the binary 44 | - Additional complexity in version management -------------------------------------------------------------------------------- /docs/design/202504-2-tools-yaml-versioning.md: -------------------------------------------------------------------------------- 1 | # 202504-2: Strict versioning for tools.yaml file 2 | 3 | **Date**: 08/04/2025 4 | 5 | ### Context 6 | With tools.yaml being externalized and allowing user customization, there's a risk of incompatibility between the tool definitions and the application code. Changes to the schema or expected tool definitions could lead to runtime errors that are difficult to diagnose. 7 | 8 | ### Decision 9 | Implement strict versioning for the tools.yaml file with version validation at startup. The application will define a required/current version, check if the provided tools.yaml file uses this version, and fail fast if there's a version mismatch. 10 | 11 | ### Rationale 12 | 1. **Compatibility Assurance** 13 | - Prevents runtime errors caused by incompatible tool definitions 14 | - Clearly communicates version requirements to users 15 | - Makes version mismatches immediately apparent 16 | 17 | 2. **Error Handling** 18 | - Provides clear error messages about version mismatches 19 | - Fails fast instead of letting subtle errors occur during operation 20 | - Guides users toward proper resolution 21 | 22 | 3. **Recovery Path** 23 | - Users can update their tools.yaml file manually to match the required version 24 | - Alternatively, users can simply delete their customized file and let the application regenerate it 25 | - Regeneration uses the embedded version which is guaranteed to be compatible 26 | 27 | 4. **Upgrade Management** 28 | - Clear versioning creates explicit upgrade paths 29 | - Version checks provide a mechanism to enforce schema migrations 30 | - Makes breaking changes in tool definitions more manageable 31 | 32 | ### Trade-offs 33 | 34 | **Benefits** 35 | - Prevents subtle runtime errors 36 | - Provides clear error messages 37 | - Offers straightforward recovery options 38 | - Makes version incompatibilities immediately apparent 39 | - Simplifies upgrade paths 40 | 41 | **Challenges** 42 | - Need to manage version numbers across releases 43 | - Must communicate version changes to users 44 | - Requires additional validation logic at startup 45 | - Necessitates documentation of version compatibility -------------------------------------------------------------------------------- /cmd/token-count/token.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "os" 7 | 8 | "github.com/portainer/portainer-mcp/pkg/toolgen" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // AnthropicTool defines the structure expected by the Anthropic API 13 | type AnthropicTool struct { 14 | Name string `json:"name"` 15 | Description string `json:"description"` 16 | InputSchema any `json:"input_schema"` 17 | // Annotations any `json:"annotations"` // Annotations are currently not supported by the Anthropic API 18 | } 19 | 20 | func main() { 21 | inputYamlPath := flag.String("input", "", "Path to the input tools YAML file (mandatory)") 22 | outputPath := flag.String("output", "", "Path to the output JSON file (mandatory)") 23 | flag.Parse() 24 | 25 | if *inputYamlPath == "" { 26 | log.Fatal().Msg("Input YAML path is mandatory. Please specify using -input flag.") 27 | } 28 | if *outputPath == "" { 29 | log.Fatal().Msg("Output path is mandatory. Please specify using -output flag.") 30 | } 31 | 32 | tools, err := toolgen.LoadToolsFromYAML(*inputYamlPath, "1.0") 33 | if err != nil { 34 | log.Fatal().Err(err).Msg("failed to load tools") 35 | } 36 | 37 | // Convert map[string]mcp.Tool to []AnthropicTool for correct JSON structure 38 | var anthropicToolList []AnthropicTool 39 | for _, tool := range tools { 40 | // Only include fields expected by Anthropic 41 | anthropicTool := AnthropicTool{ 42 | Name: tool.Name, 43 | Description: tool.Description, 44 | InputSchema: tool.InputSchema, // Assuming mcp.Tool has InputSchema field 45 | // Annotations: tool.Annotations, // Removed annotations 46 | } 47 | anthropicToolList = append(anthropicToolList, anthropicTool) 48 | } 49 | 50 | jsonData, err := json.MarshalIndent(anthropicToolList, "", " ") 51 | if err != nil { 52 | log.Fatal().Err(err).Msg("failed to marshal tools to JSON") 53 | } 54 | 55 | err = os.WriteFile(*outputPath, jsonData, 0644) 56 | if err != nil { 57 | log.Fatal().Err(err).Str("path", *outputPath).Msg("failed to write JSON to file") 58 | } 59 | 60 | log.Info().Str("path", *outputPath).Msg("Successfully wrote tools to JSON file") 61 | } 62 | -------------------------------------------------------------------------------- /internal/mcp/utils.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/mark3labs/mcp-go/mcp" 8 | ) 9 | 10 | // parseAccessMap parses access entries from an array of objects and returns a map of ID to access level 11 | func parseAccessMap(entries []any) (map[int]string, error) { 12 | accessMap := map[int]string{} 13 | 14 | for _, entry := range entries { 15 | entryMap, ok := entry.(map[string]any) 16 | if !ok { 17 | return nil, fmt.Errorf("invalid access entry: %v", entry) 18 | } 19 | 20 | id, ok := entryMap["id"].(float64) 21 | if !ok { 22 | return nil, fmt.Errorf("invalid ID: %v", entryMap["id"]) 23 | } 24 | 25 | access, ok := entryMap["access"].(string) 26 | if !ok { 27 | return nil, fmt.Errorf("invalid access: %v", entryMap["access"]) 28 | } 29 | 30 | if !isValidAccessLevel(access) { 31 | return nil, fmt.Errorf("invalid access level: %s", access) 32 | } 33 | 34 | accessMap[int(id)] = access 35 | } 36 | 37 | return accessMap, nil 38 | } 39 | 40 | // parseKeyValueMap parses a slice of map[string]any into a map[string]string, 41 | // expecting each map to have "key" and "value" string fields. 42 | func parseKeyValueMap(items []any) (map[string]string, error) { 43 | resultMap := map[string]string{} 44 | 45 | for _, item := range items { 46 | itemMap, ok := item.(map[string]any) 47 | if !ok { 48 | return nil, fmt.Errorf("invalid item: %v", item) 49 | } 50 | 51 | key, ok := itemMap["key"].(string) 52 | if !ok { 53 | return nil, fmt.Errorf("invalid key: %v", itemMap["key"]) 54 | } 55 | 56 | value, ok := itemMap["value"].(string) 57 | if !ok { 58 | return nil, fmt.Errorf("invalid value: %v", itemMap["value"]) 59 | } 60 | 61 | resultMap[key] = value 62 | } 63 | 64 | return resultMap, nil 65 | } 66 | 67 | func isValidHTTPMethod(method string) bool { 68 | validMethods := []string{"GET", "POST", "PUT", "DELETE", "HEAD"} 69 | return slices.Contains(validMethods, method) 70 | } 71 | 72 | // CreateMCPRequest creates a new MCP tool request with the given arguments 73 | func CreateMCPRequest(args map[string]any) mcp.CallToolRequest { 74 | return mcp.CallToolRequest{ 75 | Params: mcp.CallToolParams{ 76 | Arguments: args, 77 | }, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/integration/settings_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | go_mcp "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 | // TestSettingsManagement is an integration test suite that verifies the retrieval 16 | // of Portainer settings via the MCP handler. 17 | func TestSettingsManagement(t *testing.T) { 18 | env := helpers.NewTestEnv(t) 19 | defer env.Cleanup(t) 20 | 21 | // Subtest: Settings Retrieval 22 | // Verifies that: 23 | // - Settings can be correctly retrieved from the system via the MCP handler. 24 | // - The retrieved settings match the expected values after preparation. 25 | t.Run("Settings Retrieval", func(t *testing.T) { 26 | handler := env.MCPServer.HandleGetSettings() 27 | result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil)) 28 | require.NoError(t, err, "Failed to get settings via MCP handler") 29 | 30 | assert.Len(t, result.Content, 1, "Expected exactly one content block in the result") 31 | textContent, ok := result.Content[0].(go_mcp.TextContent) 32 | assert.True(t, ok, "Expected text content in response") 33 | 34 | // Unmarshal the result from the MCP handler into the local models.PortainerSettings struct 35 | var retrievedSettings models.PortainerSettings 36 | err = json.Unmarshal([]byte(textContent.Text), &retrievedSettings) 37 | require.NoError(t, err, "Failed to unmarshal retrieved settings") 38 | 39 | // Fetch settings directly via client to compare 40 | rawSettings, err := env.RawClient.GetSettings() 41 | require.NoError(t, err, "Failed to get settings directly via client for comparison") 42 | 43 | // Convert the raw settings using the package's conversion function 44 | expectedConvertedSettings := models.ConvertSettingsToPortainerSettings(rawSettings) 45 | 46 | // Compare the Settings struct from MCP handler with the one converted from the direct client call 47 | assert.Equal(t, expectedConvertedSettings, retrievedSettings, "Mismatch between MCP handler settings and converted client settings") 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/portainer/models/group_test.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 TestConvertEdgeGroupToGroup(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | edgeGroup *models.EdgegroupsDecoratedEdgeGroup 14 | want Group 15 | }{ 16 | { 17 | name: "basic edge group conversion", 18 | edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{ 19 | ID: 1, 20 | Name: "Production Servers", 21 | Endpoints: []int64{1, 2, 3}, 22 | TagIds: []int64{1, 2}, 23 | }, 24 | want: Group{ 25 | ID: 1, 26 | Name: "Production Servers", 27 | EnvironmentIds: []int{1, 2, 3}, 28 | TagIds: []int{1, 2}, 29 | }, 30 | }, 31 | { 32 | name: "edge group with no endpoints", 33 | edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{ 34 | ID: 2, 35 | Name: "Empty Group", 36 | Endpoints: []int64{}, 37 | TagIds: []int64{}, 38 | }, 39 | want: Group{ 40 | ID: 2, 41 | Name: "Empty Group", 42 | EnvironmentIds: []int{}, 43 | TagIds: []int{}, 44 | }, 45 | }, 46 | { 47 | name: "edge group with single endpoint", 48 | edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{ 49 | ID: 3, 50 | Name: "Single Server", 51 | Endpoints: []int64{4}, 52 | }, 53 | want: Group{ 54 | ID: 3, 55 | Name: "Single Server", 56 | EnvironmentIds: []int{4}, 57 | TagIds: []int{}, 58 | }, 59 | }, 60 | { 61 | name: "edge group with no tags", 62 | edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{ 63 | ID: 4, 64 | Name: "No Tags Group", 65 | Endpoints: []int64{5}, 66 | TagIds: []int64{}, 67 | }, 68 | want: Group{ 69 | ID: 4, 70 | Name: "No Tags Group", 71 | EnvironmentIds: []int{5}, 72 | TagIds: []int{}, 73 | }, 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | got := ConvertEdgeGroupToGroup(tt.edgeGroup) 80 | if !reflect.DeepEqual(got, tt.want) { 81 | t.Errorf("ConvertEdgeGroupToGroup() = %v, want %v", got, tt.want) 82 | } 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /docs/design/202503-2-tools-vs-mcp-resources.md: -------------------------------------------------------------------------------- 1 | # 202503-2: Using tools to get resources instead of MCP resources 2 | 3 | **Date**: 29/03/2025 4 | 5 | ### Context 6 | Initially, listing Portainer resources (environments, environment groups, stacks, etc.) was implemented using MCP resources. The project needed to evaluate whether this was the optimal approach given the current usage patterns and client constraints. 7 | 8 | ### Decision 9 | Replace MCP resources with tools for retrieving Portainer resources. For example, instead of exposing environments as MCP resources, provide a `listEnvironments` tool that the model can invoke. 10 | 11 | ### Rationale 12 | 1. **Client Compatibility** 13 | - Project currently relies on existing MCP clients (e.g., Claude Desktop) 14 | - MCP resources require manual selection in these clients 15 | - One-by-one resource selection creates friction in testing and iteration 16 | 17 | 2. **Protocol Design Alignment** 18 | - MCP resources are designed to be application-driven, requiring UI elements for selection 19 | - Tools are designed to be model-controlled, better matching current use case 20 | - Better alignment with the protocol's intended interaction patterns 21 | 22 | 3. **User Experience** 23 | - Models can directly request resource listings using natural language 24 | - No need for manual resource selection in the client 25 | - Faster iteration and testing cycles 26 | 27 | 4. **Model Control** 28 | - Tools provide a more direct interaction model for AI 29 | - Models can determine when and what resources to list 30 | - Approval flow is streamlined through tool invocation 31 | 32 | ### Trade-offs 33 | 34 | **Benefits** 35 | - Improved user experience through natural language requests 36 | - Faster testing and iteration cycles 37 | - Better alignment with existing client capabilities 38 | - More direct model control over resource access 39 | 40 | **Challenges** 41 | - Potential loss of MCP resource-specific features 42 | - May need to reconsider if application-driven selection becomes necessary or when we'll need to build our own client 43 | 44 | ### References 45 | - https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#user-interaction-model 46 | - https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#user-interaction-model -------------------------------------------------------------------------------- /pkg/portainer/models/access_group_test.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 TestConvertEndpointGroupToAccessGroup(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | group *models.PortainerEndpointGroup 14 | envs []*models.PortainereeEndpoint 15 | expected AccessGroup 16 | }{ 17 | { 18 | name: "group with multiple environments and accesses", 19 | group: &models.PortainerEndpointGroup{ 20 | ID: 1, 21 | Name: "Production", 22 | UserAccessPolicies: map[string]models.PortainerAccessPolicy{ 23 | "1": {RoleID: 1}, 24 | "2": {RoleID: 2}, 25 | }, 26 | TeamAccessPolicies: map[string]models.PortainerAccessPolicy{ 27 | "10": {RoleID: 3}, 28 | "20": {RoleID: 4}, 29 | }, 30 | }, 31 | envs: []*models.PortainereeEndpoint{ 32 | {ID: 100, GroupID: 1}, 33 | {ID: 101, GroupID: 1}, 34 | {ID: 102, GroupID: 2}, // Different group 35 | }, 36 | expected: AccessGroup{ 37 | ID: 1, 38 | Name: "Production", 39 | EnvironmentIds: []int{100, 101}, 40 | UserAccesses: map[int]string{ 41 | 1: "environment_administrator", 42 | 2: "helpdesk_user", 43 | }, 44 | TeamAccesses: map[int]string{ 45 | 10: "standard_user", 46 | 20: "readonly_user", 47 | }, 48 | }, 49 | }, 50 | { 51 | name: "group with no environments", 52 | group: &models.PortainerEndpointGroup{ 53 | ID: 2, 54 | Name: "Empty", 55 | UserAccessPolicies: map[string]models.PortainerAccessPolicy{ 56 | "1": {RoleID: 5}, 57 | }, 58 | TeamAccessPolicies: map[string]models.PortainerAccessPolicy{}, 59 | }, 60 | envs: []*models.PortainereeEndpoint{ 61 | {ID: 100, GroupID: 1}, // Different group 62 | }, 63 | expected: AccessGroup{ 64 | ID: 2, 65 | Name: "Empty", 66 | EnvironmentIds: []int{}, 67 | UserAccesses: map[int]string{ 68 | 1: "operator_user", 69 | }, 70 | TeamAccesses: map[int]string{}, 71 | }, 72 | }, 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | result := ConvertEndpointGroupToAccessGroup(tt.group, tt.envs) 78 | 79 | if !reflect.DeepEqual(result, tt.expected) { 80 | t.Errorf("ConvertEndpointGroupToAccessGroup() = %v, want %v", result, tt.expected) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /docs/design_summary.md: -------------------------------------------------------------------------------- 1 | # Design Documentation Summary 2 | 3 | This document provides a summary of key design decisions for the Portainer MCP project. Each decision is documented in detail in its own file. 4 | 5 | ## Design Decisions 6 | 7 | | ID | Title | Date | Description | 8 | |----|-------|------|-------------| 9 | | [202503-1](design/202503-1-external-tools-file.md) | Using an external tools file for tool definition | 29/03/2025 | Externalizes tool definitions into a YAML file for improved maintainability | 10 | | [202503-2](design/202503-2-tools-vs-mcp-resources.md) | Using tools to get resources instead of MCP resources | 29/03/2025 | Prefers tool-based resource access over MCP resources for better model control | 11 | | [202503-3](design/202503-3-specific-update-tools.md) | Specific tool for updates instead of a single update tool | 29/03/2025 | Splits update operations into specific tools for clearer parameter handling | 12 | | [202504-1](design/202504-1-embedded-tools-yaml.md) | Embedding tools.yaml in the binary | 08/04/2025 | Embeds the tools configuration file in the binary for simplified distribution | 13 | | [202504-2](design/202504-2-tools-yaml-versioning.md) | Strict versioning for tools.yaml file | 08/04/2025 | Implements versioning for tools.yaml to prevent compatibility issues | 14 | | [202504-3](design/202504-3-portainer-version-compatibility.md) | Pinning compatibility to a specific Portainer version | 08/04/2025 | Binds each release to a specific Portainer version for guaranteed compatibility | 15 | | [202504-4](design/202504-4-read-only-mode.md) | Read-only mode for enhanced security | 09/04/2025 | Provides a read-only mode to restrict modification capabilities for security | 16 | 17 | ## How to Add a New Design Decision 18 | 19 | 1. Create a new file in the `docs/design/` directory following the format: 20 | - Filename: `YYYYMM-N-short-description.md` (e.g., `202505-1-feature-toggles.md`) 21 | - Where `YYYYMM` is the date (year-month), and `N` is a sequence number for that date 22 | 23 | 2. Use the standard template structure: 24 | ``` 25 | # YYYYMM-N: Title 26 | 27 | **Date**: DD/MM/YYYY 28 | 29 | ### Context 30 | [Background and reasons for this decision] 31 | 32 | ### Decision 33 | [The decision that was made] 34 | 35 | ### Rationale 36 | [Explanation of why this decision was made] 37 | 38 | ### Trade-offs 39 | [Benefits and challenges of this approach] 40 | ``` 41 | 42 | 3. Add the decision to the table in this summary document -------------------------------------------------------------------------------- /docs/design/202503-3-specific-update-tools.md: -------------------------------------------------------------------------------- 1 | # 202503-3: Specific tool for updates instead of a single update tool 2 | 3 | **Date**: 29/03/2025 4 | 5 | ### Context 6 | Initially, resource updates (such as access groups, environments, etc.) were handled through single, multi-purpose update tools that could modify multiple properties at once. This approach led to complex parameter handling and unclear behavior around optional values. 7 | 8 | ### Decision 9 | Split update operations into multiple specific tools, each responsible for updating a single property or related set of properties. For example, instead of a single `updateAccessGroup` tool, create separate tools like: 10 | - `updateAccessGroupName` 11 | - `updateAccessGroupUserAccesses` 12 | - `updateAccessGroupTeamAccesses` 13 | 14 | ### Rationale 15 | 1. **Parameter Clarity** 16 | - Each tool has clear, required parameters 17 | - No ambiguity between undefined parameters and empty values 18 | - Eliminates need for complex optional parameter handling 19 | 20 | 2. **Code Simplification** 21 | - Removes need for pointer types in parameter handling 22 | - Clearer validation of required parameters 23 | - Simpler implementation of each specific update operation 24 | 25 | 3. **Maintenance Benefits** 26 | - Each tool has a single responsibility 27 | - Easier to test individual update operations 28 | - Clearer documentation of available operations 29 | 30 | 4. **Model Interaction** 31 | - Models can clearly understand which property they're updating 32 | - More explicit about the changes being made 33 | - Better alignment with natural language commands 34 | 35 | ### Trade-offs 36 | 37 | **Benefits** 38 | - Clearer parameter requirements and validation 39 | - Simpler code without pointer logic 40 | - Better separation of concerns 41 | - More explicit and focused tools 42 | - Easier testing and maintenance 43 | 44 | **Challenges** 45 | - Multiple API calls needed for updating multiple properties 46 | - Slightly increased network traffic for multi-property updates 47 | - More tool definitions to maintain 48 | - No atomic updates across multiple properties 49 | - More tools might clutter the context of the model 50 | - Some clients have a hard limit on the number of tools that can be used/enabled 51 | 52 | ### Notes 53 | Performance impact of multiple API calls is considered acceptable given: 54 | - Non-performance-critical context 55 | - Relatively low frequency of update operations 56 | - Benefits of simpler code and clearer behavior outweigh the overhead -------------------------------------------------------------------------------- /pkg/portainer/models/user_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/portainer/client-api-go/v2/pkg/models" 7 | ) 8 | 9 | func TestConvertToUser(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input *models.PortainereeUser 13 | expected User 14 | }{ 15 | { 16 | name: "admin user", 17 | input: &models.PortainereeUser{ 18 | ID: 1, 19 | Username: "admin", 20 | Role: 1, 21 | }, 22 | expected: User{ 23 | ID: 1, 24 | Username: "admin", 25 | Role: UserRoleAdmin, 26 | }, 27 | }, 28 | { 29 | name: "regular user", 30 | input: &models.PortainereeUser{ 31 | ID: 2, 32 | Username: "user1", 33 | Role: 2, 34 | }, 35 | expected: User{ 36 | ID: 2, 37 | Username: "user1", 38 | Role: UserRoleUser, 39 | }, 40 | }, 41 | { 42 | name: "edge admin user", 43 | input: &models.PortainereeUser{ 44 | ID: 3, 45 | Username: "edge_admin", 46 | Role: 3, 47 | }, 48 | expected: User{ 49 | ID: 3, 50 | Username: "edge_admin", 51 | Role: UserRoleEdgeAdmin, 52 | }, 53 | }, 54 | } 55 | 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | result := ConvertToUser(tt.input) 59 | if result != tt.expected { 60 | t.Errorf("ConvertToUser() = %v, want %v", result, tt.expected) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | func TestConvertUserRole(t *testing.T) { 67 | tests := []struct { 68 | name string 69 | input *models.PortainereeUser 70 | expected string 71 | }{ 72 | { 73 | name: "admin role", 74 | input: &models.PortainereeUser{Role: 1}, 75 | expected: UserRoleAdmin, 76 | }, 77 | { 78 | name: "user role", 79 | input: &models.PortainereeUser{Role: 2}, 80 | expected: UserRoleUser, 81 | }, 82 | { 83 | name: "edge admin role", 84 | input: &models.PortainereeUser{Role: 3}, 85 | expected: UserRoleEdgeAdmin, 86 | }, 87 | { 88 | name: "unknown role", 89 | input: &models.PortainereeUser{Role: 999}, 90 | expected: UserRoleUnknown, 91 | }, 92 | } 93 | 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | result := convertUserRole(tt.input) 97 | if result != tt.expected { 98 | t.Errorf("convertUserRole() = %v, want %v", result, tt.expected) 99 | } 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pkg/portainer/models/access_policy_test.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 TestConvertAccessPolicyRole(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | role *models.PortainerAccessPolicy 14 | expected string 15 | }{ 16 | { 17 | name: "environment administrator role", 18 | role: &models.PortainerAccessPolicy{RoleID: 1}, 19 | expected: "environment_administrator", 20 | }, 21 | { 22 | name: "helpdesk user role", 23 | role: &models.PortainerAccessPolicy{RoleID: 2}, 24 | expected: "helpdesk_user", 25 | }, 26 | { 27 | name: "standard user role", 28 | role: &models.PortainerAccessPolicy{RoleID: 3}, 29 | expected: "standard_user", 30 | }, 31 | { 32 | name: "readonly user role", 33 | role: &models.PortainerAccessPolicy{RoleID: 4}, 34 | expected: "readonly_user", 35 | }, 36 | { 37 | name: "operator user role", 38 | role: &models.PortainerAccessPolicy{RoleID: 5}, 39 | expected: "operator_user", 40 | }, 41 | { 42 | name: "unknown role", 43 | role: &models.PortainerAccessPolicy{RoleID: 999}, 44 | expected: "unknown", 45 | }, 46 | } 47 | 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | result := convertAccessPolicyRole(tt.role) 51 | if result != tt.expected { 52 | t.Errorf("convertAccessPolicyRole() = %v, want %v", result, tt.expected) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestConvertAccesses(t *testing.T) { 59 | t.Run("user accesses", func(t *testing.T) { 60 | policies := models.PortainerUserAccessPolicies{ 61 | "1": models.PortainerAccessPolicy{RoleID: 1}, 62 | "2": models.PortainerAccessPolicy{RoleID: 3}, 63 | } 64 | expected := map[int]string{ 65 | 1: "environment_administrator", 66 | 2: "standard_user", 67 | } 68 | result := convertAccesses(policies) 69 | if !reflect.DeepEqual(result, expected) { 70 | t.Errorf("convertAccesses() = %v, want %v", result, expected) 71 | } 72 | }) 73 | 74 | t.Run("team accesses", func(t *testing.T) { 75 | policies := models.PortainerTeamAccessPolicies{ 76 | "10": models.PortainerAccessPolicy{RoleID: 1}, 77 | "20": models.PortainerAccessPolicy{RoleID: 4}, 78 | } 79 | expected := map[int]string{ 80 | 10: "environment_administrator", 81 | 20: "readonly_user", 82 | } 83 | result := convertAccesses(policies) 84 | if !reflect.DeepEqual(result, expected) { 85 | t.Errorf("convertAccesses() = %v, want %v", result, expected) 86 | } 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /cmd/portainer-mcp/mcp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/portainer/portainer-mcp/internal/mcp" 7 | "github.com/portainer/portainer-mcp/internal/tooldef" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | const defaultToolsPath = "tools.yaml" 12 | 13 | var ( 14 | Version string 15 | BuildDate string 16 | Commit string 17 | ) 18 | 19 | func main() { 20 | log.Info(). 21 | Str("version", Version). 22 | Str("build-date", BuildDate). 23 | Str("commit", Commit). 24 | Msg("Portainer MCP server") 25 | 26 | serverFlag := flag.String("server", "", "The Portainer server URL") 27 | tokenFlag := flag.String("token", "", "The authentication token for the Portainer server") 28 | toolsFlag := flag.String("tools", "", "The path to the tools YAML file") 29 | readOnlyFlag := flag.Bool("read-only", false, "Run in read-only mode") 30 | disableVersionCheckFlag := flag.Bool("disable-version-check", false, "Disable Portainer server version check") 31 | 32 | flag.Parse() 33 | 34 | if *serverFlag == "" || *tokenFlag == "" { 35 | log.Fatal().Msg("Both -server and -token flags are required") 36 | } 37 | 38 | toolsPath := *toolsFlag 39 | if toolsPath == "" { 40 | toolsPath = defaultToolsPath 41 | } 42 | 43 | // We first check if the tools.yaml file exists 44 | // We'll create it from the embedded version if it doesn't exist 45 | exists, err := tooldef.CreateToolsFileIfNotExists(toolsPath) 46 | if err != nil { 47 | log.Fatal().Err(err).Msg("failed to create tools.yaml file") 48 | } 49 | 50 | if exists { 51 | log.Info().Msg("using existing tools.yaml file") 52 | } else { 53 | log.Info().Msg("created tools.yaml file") 54 | } 55 | 56 | log.Info(). 57 | Str("portainer-host", *serverFlag). 58 | Str("tools-path", toolsPath). 59 | Bool("read-only", *readOnlyFlag). 60 | Bool("disable-version-check", *disableVersionCheckFlag). 61 | Msg("starting MCP server") 62 | 63 | server, err := mcp.NewPortainerMCPServer(*serverFlag, *tokenFlag, toolsPath, mcp.WithReadOnly(*readOnlyFlag), mcp.WithDisableVersionCheck(*disableVersionCheckFlag)) 64 | if err != nil { 65 | log.Fatal().Err(err).Msg("failed to create server") 66 | } 67 | 68 | server.AddEnvironmentFeatures() 69 | server.AddEnvironmentGroupFeatures() 70 | server.AddTagFeatures() 71 | server.AddStackFeatures() 72 | server.AddSettingsFeatures() 73 | server.AddUserFeatures() 74 | server.AddTeamFeatures() 75 | server.AddAccessGroupFeatures() 76 | server.AddDockerProxyFeatures() 77 | server.AddKubernetesProxyFeatures() 78 | 79 | err = server.Start() 80 | if err != nil { 81 | log.Fatal().Err(err).Msg("failed to start server") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pkg/portainer/models/stack_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "reflect" 8 | 9 | "github.com/portainer/client-api-go/v2/pkg/models" 10 | ) 11 | 12 | func TestConvertEdgeStackToStack(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | edgeStack *models.PortainereeEdgeStack 16 | want Stack 17 | }{ 18 | { 19 | name: "basic edge stack conversion", 20 | edgeStack: &models.PortainereeEdgeStack{ 21 | ID: 1, 22 | Name: "Web Application Stack", 23 | CreationDate: 1609459200, // 2021-01-01 00:00:00 UTC 24 | EdgeGroups: []int64{1, 2, 3}, 25 | }, 26 | want: Stack{ 27 | ID: 1, 28 | Name: "Web Application Stack", 29 | CreatedAt: "2021-01-01T00:00:00Z", 30 | EnvironmentGroupIds: []int{1, 2, 3}, 31 | }, 32 | }, 33 | { 34 | name: "edge stack with no groups", 35 | edgeStack: &models.PortainereeEdgeStack{ 36 | ID: 2, 37 | Name: "Empty Stack", 38 | CreationDate: 1640995200, // 2022-01-01 00:00:00 UTC 39 | EdgeGroups: []int64{}, 40 | }, 41 | want: Stack{ 42 | ID: 2, 43 | Name: "Empty Stack", 44 | CreatedAt: "2022-01-01T00:00:00Z", 45 | EnvironmentGroupIds: []int{}, 46 | }, 47 | }, 48 | { 49 | name: "edge stack with single group", 50 | edgeStack: &models.PortainereeEdgeStack{ 51 | ID: 3, 52 | Name: "Single Group Stack", 53 | CreationDate: 1672531200, // 2023-01-01 00:00:00 UTC 54 | EdgeGroups: []int64{4}, 55 | }, 56 | want: Stack{ 57 | ID: 3, 58 | Name: "Single Group Stack", 59 | CreatedAt: "2023-01-01T00:00:00Z", 60 | EnvironmentGroupIds: []int{4}, 61 | }, 62 | }, 63 | { 64 | name: "edge stack with current timestamp", 65 | edgeStack: &models.PortainereeEdgeStack{ 66 | ID: 4, 67 | Name: "Recent Stack", 68 | CreationDate: time.Now().Add(-24 * time.Hour).Unix(), // Yesterday 69 | EdgeGroups: []int64{1, 2}, 70 | }, 71 | want: Stack{ 72 | ID: 4, 73 | Name: "Recent Stack", 74 | CreatedAt: time.Unix(time.Now().Add(-24*time.Hour).Unix(), 0).Format(time.RFC3339), 75 | EnvironmentGroupIds: []int{1, 2}, 76 | }, 77 | }, 78 | } 79 | 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | got := ConvertEdgeStackToStack(tt.edgeStack) 83 | if !reflect.DeepEqual(got, tt.want) { 84 | t.Errorf("ConvertEdgeStackToStack() = %v, want %v", got, tt.want) 85 | } 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /docs/design/202504-3-portainer-version-compatibility.md: -------------------------------------------------------------------------------- 1 | # 202504-3: Pinning compatibility to a specific Portainer version 2 | 3 | **Date**: 08/04/2025 4 | 5 | ### Context 6 | Portainer server does not implement API versioning, making it challenging to ensure compatibility between our software and different Portainer server versions. Each version of Portainer may have different API behaviors and endpoints, which could cause runtime errors or unexpected behavior if not properly managed. 7 | 8 | ### Decision 9 | Maintain independent versioning for this software while explicitly pinning compatibility to a specific Portainer server version. The software will validate the Portainer server version at startup and fail fast if the detected version does not match the required version exactly. Documentation will clearly indicate which exact Portainer version is supported by each software release. 10 | 11 | ### Rationale 12 | 1. **Independent Release Cycle** 13 | - Software can be updated outside of the Portainer release lifecycle 14 | - Allows for bug fixes and features without waiting for Portainer releases 15 | - Enables more frequent iterations and improvements 16 | 17 | 2. **Exact Compatibility** 18 | - Each release will document the specific Portainer version it supports 19 | - Strict version checking at startup prevents compatibility issues 20 | - Ensures 100% compatibility with the supported API endpoints 21 | 22 | 3. **SDK Alignment** 23 | - Software will use a Go SDK version that matches exactly the supported Portainer version 24 | - Creates a precise binding between SDK capabilities and software functionality 25 | - Eliminates ambiguity about supported functionality 26 | 27 | 4. **Error Prevention** 28 | - Early validation of the exact Portainer version prevents any API compatibility issues 29 | - Users receive clear error messages when the version doesn't match 30 | - Completely eliminates support requests related to API incompatibilities 31 | 32 | ### Trade-offs 33 | 34 | **Benefits** 35 | - Flexible release schedule independent of Portainer 36 | - Absolute certainty about compatibility requirements 37 | - Fail-fast behavior for unsupported versions 38 | - Predictable behavior with supported Portainer version 39 | - Simplified testing against a single Portainer version 40 | 41 | **Challenges** 42 | - Users must upgrade/downgrade Portainer to the exact supported version 43 | - Each software release requires a new version when supporting a new Portainer version 44 | - More restrictive for users who can't easily change their Portainer version 45 | - Overhead of version validation at startup 46 | - Need to clearly communicate the exact supported version in all documentation -------------------------------------------------------------------------------- /pkg/portainer/models/team_test.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 TestConvertToTeam(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | team *models.PortainerTeam 14 | memberships []*models.PortainerTeamMembership 15 | expectedTeam Team 16 | }{ 17 | { 18 | name: "team with multiple members", 19 | team: &models.PortainerTeam{ 20 | ID: 1, 21 | Name: "DevOps", 22 | }, 23 | memberships: []*models.PortainerTeamMembership{ 24 | {TeamID: 1, UserID: 100}, 25 | {TeamID: 1, UserID: 101}, 26 | {TeamID: 1, UserID: 102}, 27 | {TeamID: 2, UserID: 200}, // Different team, should be ignored 28 | }, 29 | expectedTeam: Team{ 30 | ID: 1, 31 | Name: "DevOps", 32 | MemberIDs: []int{100, 101, 102}, 33 | }, 34 | }, 35 | { 36 | name: "team with no members", 37 | team: &models.PortainerTeam{ 38 | ID: 2, 39 | Name: "Empty Team", 40 | }, 41 | memberships: []*models.PortainerTeamMembership{ 42 | {TeamID: 1, UserID: 100}, // Different team 43 | {TeamID: 3, UserID: 300}, // Different team 44 | }, 45 | expectedTeam: Team{ 46 | ID: 2, 47 | Name: "Empty Team", 48 | MemberIDs: []int{}, 49 | }, 50 | }, 51 | { 52 | name: "team with single member", 53 | team: &models.PortainerTeam{ 54 | ID: 3, 55 | Name: "Solo Team", 56 | }, 57 | memberships: []*models.PortainerTeamMembership{ 58 | {TeamID: 3, UserID: 300}, 59 | }, 60 | expectedTeam: Team{ 61 | ID: 3, 62 | Name: "Solo Team", 63 | MemberIDs: []int{300}, 64 | }, 65 | }, 66 | { 67 | name: "team with empty memberships list", 68 | team: &models.PortainerTeam{ 69 | ID: 4, 70 | Name: "New Team", 71 | }, 72 | memberships: []*models.PortainerTeamMembership{}, 73 | expectedTeam: Team{ 74 | ID: 4, 75 | Name: "New Team", 76 | MemberIDs: []int{}, 77 | }, 78 | }, 79 | } 80 | 81 | for _, tt := range tests { 82 | t.Run(tt.name, func(t *testing.T) { 83 | result := ConvertToTeam(tt.team, tt.memberships) 84 | 85 | if result.ID != tt.expectedTeam.ID { 86 | t.Errorf("ID mismatch: got %v, want %v", result.ID, tt.expectedTeam.ID) 87 | } 88 | 89 | if result.Name != tt.expectedTeam.Name { 90 | t.Errorf("Name mismatch: got %v, want %v", result.Name, tt.expectedTeam.Name) 91 | } 92 | 93 | if !reflect.DeepEqual(result.MemberIDs, tt.expectedTeam.MemberIDs) { 94 | t.Errorf("MemberIDs mismatch: got %v, want %v", result.MemberIDs, tt.expectedTeam.MemberIDs) 95 | } 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /docs/design/202504-4-read-only-mode.md: -------------------------------------------------------------------------------- 1 | # 202504-4: Read-only mode for enhanced security 2 | 3 | **Date**: 09/04/2025 4 | 5 | ### Context 6 | Model Context Protocol (MCP) is a relatively new technology with varying levels of trust among infrastructure operators. There are significant concerns about potential security risks when allowing AI models to modify production resources. These concerns are heightened by the growing awareness of prompt injection attacks, model hallucinations, and other LLM-specific vulnerabilities that could be exploited to trigger unintended operations on critical infrastructure. Portainer often manages production container environments, making the security implications particularly serious. 7 | 8 | ### Decision 9 | Implement a read-only flag that can be specified at application startup. When this flag is enabled, the application will only register and expose read-oriented tools, completely omitting any tools capable of modifying Portainer resources. 10 | 11 | ### Rationale 12 | 1. **Security Enhancement** 13 | - Eliminates risk of accidental or unauthorized modifications to production environments 14 | - Provides a safe mode for users to explore and monitor without modification capabilities 15 | - Creates a clear separation between monitoring and management use cases 16 | 17 | 2. **Operational Safety** 18 | - Enables safe usage in sensitive production environments 19 | - Reduces potential impact of prompt injection or model hallucination issues 20 | - Provides an additional layer of protection for critical infrastructure 21 | 22 | 3. **User Trust** 23 | - Addresses concerns of security-conscious users about potential write implications 24 | - Creates confidence that the application cannot modify resources when in read-only mode 25 | - Offers a path for skeptical users to start with limited capabilities before enabling full functionality 26 | 27 | 4. **Use Case Alignment** 28 | - Matches common use case of "explore first, modify later" workflow 29 | - Supports read-only scenarios like monitoring, auditing, and documentation 30 | - Creates a clear distinction between observability and management roles 31 | 32 | ### Trade-offs 33 | 34 | **Benefits** 35 | - Enhanced security posture for sensitive environments 36 | - Reduced risk surface for production deployments 37 | - Builds user trust through clear capability boundaries 38 | - Better alignment with specific read-only use cases 39 | - Allows progressive adoption starting with read-only mode 40 | 41 | **Challenges** 42 | - Need to categorize tools as read or write operations 43 | - Additional startup mode to test and maintain 44 | - Potential user confusion about available capabilities in each mode 45 | - May require switching between modes for different workflows 46 | - Reduced functionality in read-only mode may limit some complex scenarios -------------------------------------------------------------------------------- /internal/tooldef/tooldef_test.go: -------------------------------------------------------------------------------- 1 | package tooldef 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestCreateToolsFileIfNotExists(t *testing.T) { 13 | // Create a temporary directory for testing 14 | tempDir, err := os.MkdirTemp("", "tooldef-test") 15 | require.NoError(t, err, "Failed to create temporary directory") 16 | defer os.RemoveAll(tempDir) 17 | 18 | t.Run("File Does Not Exist", func(t *testing.T) { 19 | // Define a path for a non-existent file 20 | filePath := filepath.Join(tempDir, "new-tools.yaml") 21 | 22 | // Verify the file doesn't exist initially 23 | _, err := os.Stat(filePath) 24 | assert.True(t, os.IsNotExist(err), "File should not exist before test") 25 | 26 | exists, err := CreateToolsFileIfNotExists(filePath) 27 | require.NoError(t, err, "Function should not return an error") 28 | assert.False(t, exists, "Function should return false when creating a new file") 29 | 30 | // Verify the file was created 31 | _, err = os.Stat(filePath) 32 | assert.NoError(t, err, "File should exist after function call") 33 | 34 | // Verify the file has the embedded content 35 | content, err := os.ReadFile(filePath) 36 | require.NoError(t, err, "Should be able to read the created file") 37 | assert.Equal(t, ToolsFile, content, "File should contain the embedded tools content") 38 | }) 39 | 40 | t.Run("File Already Exists", func(t *testing.T) { 41 | // Define a path for an existing file 42 | filePath := filepath.Join(tempDir, "existing-tools.yaml") 43 | 44 | // Create a custom file 45 | customContent := []byte("# Custom tools file content") 46 | err := os.WriteFile(filePath, customContent, 0644) 47 | require.NoError(t, err, "Failed to create test file") 48 | 49 | exists, err := CreateToolsFileIfNotExists(filePath) 50 | require.NoError(t, err, "Function should not return an error") 51 | assert.True(t, exists, "Function should return true when file already exists") 52 | 53 | // Verify the file content was not changed 54 | content, err := os.ReadFile(filePath) 55 | require.NoError(t, err, "Should be able to read the existing file") 56 | assert.Equal(t, customContent, content, "Function should not modify an existing file") 57 | }) 58 | 59 | t.Run("Error During File Creation", func(t *testing.T) { 60 | // Create a path in a non-existent directory to force a file creation error 61 | nonExistentDir := filepath.Join(tempDir, "this-directory-does-not-exist", "neither-does-this-one") 62 | filePath := filepath.Join(nonExistentDir, "tools.yaml") 63 | 64 | exists, err := CreateToolsFileIfNotExists(filePath) 65 | assert.Error(t, err, "Function should return an error when file creation fails") 66 | assert.False(t, exists, "Function should return false when an error occurs") 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /internal/mcp/settings_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | "github.com/portainer/portainer-mcp/pkg/portainer/models" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestHandleGetSettings(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | settings models.PortainerSettings 18 | mockError error 19 | expectError bool 20 | errorContains string 21 | }{ 22 | { 23 | name: "successful settings retrieval", 24 | settings: models.PortainerSettings{ 25 | Authentication: struct { 26 | Method string `json:"method"` 27 | }{ 28 | Method: models.AuthenticationMethodInternal, 29 | }, 30 | Edge: struct { 31 | Enabled bool `json:"enabled"` 32 | ServerURL string `json:"server_url"` 33 | }{ 34 | Enabled: true, 35 | ServerURL: "https://example.com", 36 | }, 37 | }, 38 | mockError: nil, 39 | expectError: false, 40 | }, 41 | { 42 | name: "client error", 43 | settings: models.PortainerSettings{}, 44 | mockError: assert.AnError, 45 | expectError: true, 46 | errorContains: "failed to get settings", 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | // Create mock client 53 | mockClient := new(MockPortainerClient) 54 | mockClient.On("GetSettings").Return(tt.settings, tt.mockError) 55 | 56 | // Create server with mock client 57 | srv := &PortainerMCPServer{ 58 | srv: server.NewMCPServer("Test Server", "1.0.0"), 59 | cli: mockClient, 60 | tools: make(map[string]mcp.Tool), 61 | } 62 | 63 | // Get the handler 64 | handler := srv.HandleGetSettings() 65 | 66 | // Call the handler 67 | result, err := handler(context.Background(), mcp.CallToolRequest{}) 68 | 69 | if tt.expectError { 70 | assert.NoError(t, err) 71 | assert.NotNil(t, result) 72 | assert.True(t, result.IsError, "result.IsError should be true for API errors") 73 | assert.Len(t, result.Content, 1) 74 | textContent, ok := result.Content[0].(mcp.TextContent) 75 | assert.True(t, ok, "Result content should be mcp.TextContent") 76 | if tt.errorContains != "" { 77 | assert.Contains(t, textContent.Text, tt.errorContains) 78 | } 79 | } else { 80 | assert.NoError(t, err) 81 | assert.NotNil(t, result) 82 | assert.Len(t, result.Content, 1) 83 | textContent, ok := result.Content[0].(mcp.TextContent) 84 | assert.True(t, ok) 85 | 86 | var settings models.PortainerSettings 87 | err = json.Unmarshal([]byte(textContent.Text), &settings) 88 | assert.NoError(t, err) 89 | assert.Equal(t, tt.settings, settings) 90 | } 91 | 92 | // Verify mock expectations 93 | mockClient.AssertExpectations(t) 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /pkg/portainer/client/stack.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 | // GetStacks retrieves all stacks from the Portainer server. 11 | // Stacks are the equivalent of Edge Stacks in Portainer. 12 | // 13 | // Returns: 14 | // - A slice of Stack objects 15 | // - An error if the operation fails 16 | func (c *PortainerClient) GetStacks() ([]models.Stack, error) { 17 | edgeStacks, err := c.cli.ListEdgeStacks() 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to list edge stacks: %w", err) 20 | } 21 | 22 | stacks := make([]models.Stack, len(edgeStacks)) 23 | for i, es := range edgeStacks { 24 | stacks[i] = models.ConvertEdgeStackToStack(es) 25 | } 26 | 27 | return stacks, nil 28 | } 29 | 30 | // GetStackFile retrieves the file content of a stack from the Portainer server. 31 | // Stacks are the equivalent of Edge Stacks in Portainer. 32 | // 33 | // Parameters: 34 | // - id: The ID of the stack to retrieve 35 | // 36 | // Returns: 37 | // - The file content of the stack (Compose file) 38 | // - An error if the operation fails 39 | func (c *PortainerClient) GetStackFile(id int) (string, error) { 40 | file, err := c.cli.GetEdgeStackFile(int64(id)) 41 | if err != nil { 42 | return "", fmt.Errorf("failed to get edge stack file: %w", err) 43 | } 44 | 45 | return file, nil 46 | } 47 | 48 | // CreateStack creates a new stack on the Portainer server. 49 | // This function specifically creates a Docker Compose stack. 50 | // Stacks are the equivalent of Edge Stacks in Portainer. 51 | // 52 | // Parameters: 53 | // - name: The name of the stack 54 | // - file: The file content of the stack (Compose file) 55 | // - environmentGroupIds: A slice of environment group IDs to include in the stack 56 | // 57 | // Returns: 58 | // - The ID of the created stack 59 | // - An error if the operation fails 60 | func (c *PortainerClient) CreateStack(name, file string, environmentGroupIds []int) (int, error) { 61 | id, err := c.cli.CreateEdgeStack(name, file, utils.IntToInt64Slice(environmentGroupIds)) 62 | if err != nil { 63 | return 0, fmt.Errorf("failed to create edge stack: %w", err) 64 | } 65 | 66 | return int(id), nil 67 | } 68 | 69 | // UpdateStack updates an existing stack on the Portainer server. 70 | // This function specifically updates a Docker Compose stack. 71 | // Stacks are the equivalent of Edge Stacks in Portainer. 72 | // 73 | // Parameters: 74 | // - id: The ID of the stack to update 75 | // - file: The file content of the stack (Compose file) 76 | // - environmentGroupIds: A slice of environment group IDs to include in the stack 77 | // 78 | // Returns: 79 | // - An error if the operation fails 80 | func (c *PortainerClient) UpdateStack(id int, file string, environmentGroupIds []int) error { 81 | err := c.cli.UpdateEdgeStack(int64(id), file, utils.IntToInt64Slice(environmentGroupIds)) 82 | if err != nil { 83 | return fmt.Errorf("failed to update edge stack: %w", err) 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /internal/mcp/docker.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/pkg/portainer/models" 12 | "github.com/portainer/portainer-mcp/pkg/toolgen" 13 | ) 14 | 15 | func (s *PortainerMCPServer) AddDockerProxyFeatures() { 16 | if !s.readOnly { 17 | s.addToolIfExists(ToolDockerProxy, s.HandleDockerProxy()) 18 | } 19 | } 20 | 21 | func (s *PortainerMCPServer) HandleDockerProxy() server.ToolHandlerFunc { 22 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 23 | parser := toolgen.NewParameterParser(request) 24 | 25 | environmentId, err := parser.GetInt("environmentId", true) 26 | if err != nil { 27 | return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil 28 | } 29 | 30 | method, err := parser.GetString("method", true) 31 | if err != nil { 32 | return mcp.NewToolResultErrorFromErr("invalid method parameter", err), nil 33 | } 34 | if !isValidHTTPMethod(method) { 35 | return mcp.NewToolResultError(fmt.Sprintf("invalid method: %s", method)), nil 36 | } 37 | 38 | dockerAPIPath, err := parser.GetString("dockerAPIPath", true) 39 | if err != nil { 40 | return mcp.NewToolResultErrorFromErr("invalid dockerAPIPath parameter", err), nil 41 | } 42 | if !strings.HasPrefix(dockerAPIPath, "/") { 43 | return mcp.NewToolResultError("dockerAPIPath must start with a leading slash"), nil 44 | } 45 | 46 | queryParams, err := parser.GetArrayOfObjects("queryParams", false) 47 | if err != nil { 48 | return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil 49 | } 50 | queryParamsMap, err := parseKeyValueMap(queryParams) 51 | if err != nil { 52 | return mcp.NewToolResultErrorFromErr("invalid query params", err), nil 53 | } 54 | 55 | headers, err := parser.GetArrayOfObjects("headers", false) 56 | if err != nil { 57 | return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil 58 | } 59 | headersMap, err := parseKeyValueMap(headers) 60 | if err != nil { 61 | return mcp.NewToolResultErrorFromErr("invalid headers", err), nil 62 | } 63 | 64 | body, err := parser.GetString("body", false) 65 | if err != nil { 66 | return mcp.NewToolResultErrorFromErr("invalid body parameter", err), nil 67 | } 68 | 69 | opts := models.DockerProxyRequestOptions{ 70 | EnvironmentID: environmentId, 71 | Path: dockerAPIPath, 72 | Method: method, 73 | QueryParams: queryParamsMap, 74 | Headers: headersMap, 75 | } 76 | 77 | if body != "" { 78 | opts.Body = strings.NewReader(body) 79 | } 80 | 81 | response, err := s.cli.ProxyDockerRequest(opts) 82 | if err != nil { 83 | return mcp.NewToolResultErrorFromErr("failed to send Docker API request", err), nil 84 | } 85 | 86 | responseBody, err := io.ReadAll(response.Body) 87 | if err != nil { 88 | return mcp.NewToolResultErrorFromErr("failed to read Docker API response", err), nil 89 | } 90 | 91 | return mcp.NewToolResultText(string(responseBody)), nil 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /token.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script calculates the estimated token count for a given set of tools 4 | # and a sample message using the Anthropic API's /v1/messages/count_tokens endpoint. 5 | # It reads tool definitions from a JSON file generated by the `cmd/token-count` Go program. 6 | # 7 | # Requires: 8 | # - curl : For making HTTP requests to the Anthropic API. 9 | # - jq : For constructing the JSON payload from the input file. 10 | # 11 | # Usage: 12 | # ./token.sh -k -i 13 | # 14 | # Mandatory Arguments: 15 | # -k, --api-key : Your Anthropic API key. 16 | # -i, --input-json : Path to the JSON file containing the tool definitions. 17 | # This file should be an array of tool objects, each having 18 | # `name`, `description`, and `input_schema` fields. 19 | # 20 | # Example: 21 | # # Assuming tools.json is in the current directory 22 | # ./token.sh -k sk-ant-xxxxxxxx -i tools.json 23 | # 24 | # Output: 25 | # The script outputs the JSON response from the Anthropic API, 26 | # which typically includes the calculated token count. 27 | 28 | # Default values 29 | API_KEY="" 30 | INPUT_JSON="" 31 | 32 | # Parse command-line arguments 33 | TEMP=$(getopt -o k:i: --long api-key:,input-json: -n 'token.sh' -- "$@") 34 | if [ $? != 0 ] ; then echo "Terminating..." >&2 ; exit 1 ; fi 35 | 36 | # Note the quotes around `$TEMP`: they are essential! 37 | eval set -- "$TEMP" 38 | 39 | while true; do 40 | case "$1" in 41 | -k | --api-key ) API_KEY="$2"; shift 2 ;; 42 | -i | --input-json ) INPUT_JSON="$2"; shift 2 ;; 43 | -- ) shift; break ;; 44 | * ) break ;; 45 | esac 46 | done 47 | 48 | # Validate mandatory arguments 49 | if [ -z "$API_KEY" ]; then 50 | echo "Error: API Key is mandatory. Use -k or --api-key." >&2 51 | exit 1 52 | fi 53 | 54 | if [ -z "$INPUT_JSON" ]; then 55 | echo "Error: Input JSON file path is mandatory. Use -i or --input-json." >&2 56 | exit 1 57 | fi 58 | 59 | if [ ! -f "$INPUT_JSON" ]; then 60 | echo "Error: Input JSON file not found: $INPUT_JSON" >&2 61 | exit 1 62 | fi 63 | 64 | # Read tools definition from the input JSON file 65 | TOOLS_JSON=$(cat "$INPUT_JSON") 66 | 67 | # Construct the JSON payload using jq 68 | # Note: We keep the example message structure for now. 69 | # We pass the tools JSON as a string argument to jq and use --argjson to parse it. 70 | JSON_PAYLOAD=$(jq -n --argjson tools "$TOOLS_JSON" '{ 71 | model: "claude-3-7-sonnet-20250219", 72 | tools: $tools, 73 | messages: [ 74 | { 75 | role: "user", 76 | content: "Show me a list of Portainer environments." 77 | } 78 | ] 79 | }') 80 | 81 | # Check if jq succeeded 82 | if [ $? != 0 ]; then 83 | echo "Error: Failed to construct JSON payload with jq. Is the input JSON valid?" >&2 84 | exit 1 85 | fi 86 | 87 | 88 | # Make the API call 89 | curl https://api.anthropic.com/v1/messages/count_tokens \ 90 | --header "x-api-key: $API_KEY" \ 91 | --header "content-type: application/json" \ 92 | --header "anthropic-version: 2023-06-01" \ 93 | --data "$JSON_PAYLOAD" 94 | 95 | echo # Add a newline for cleaner output 96 | -------------------------------------------------------------------------------- /pkg/portainer/client/tag_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 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 TestGetEnvironmentTags(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | mockTags []*apimodels.PortainerTag 16 | mockError error 17 | expectedTags []models.EnvironmentTag 18 | expectedError bool 19 | }{ 20 | { 21 | name: "successful retrieval", 22 | mockTags: []*apimodels.PortainerTag{ 23 | {ID: 1, Name: "prod"}, 24 | {ID: 2, Name: "dev"}, 25 | }, 26 | mockError: nil, 27 | expectedTags: []models.EnvironmentTag{ 28 | {ID: 1, Name: "prod", EnvironmentIds: []int{}}, 29 | {ID: 2, Name: "dev", EnvironmentIds: []int{}}, 30 | }, 31 | expectedError: false, 32 | }, 33 | { 34 | name: "empty tags list", 35 | mockTags: []*apimodels.PortainerTag{}, 36 | mockError: nil, 37 | expectedTags: []models.EnvironmentTag{}, 38 | expectedError: false, 39 | }, 40 | { 41 | name: "api error", 42 | mockTags: nil, 43 | mockError: fmt.Errorf("api error"), 44 | expectedTags: nil, 45 | expectedError: true, 46 | }, 47 | } 48 | 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | mockAPI := new(MockPortainerAPI) 52 | mockAPI.On("ListTags").Return(tt.mockTags, tt.mockError) 53 | 54 | client := &PortainerClient{ 55 | cli: mockAPI, 56 | } 57 | 58 | tags, err := client.GetEnvironmentTags() 59 | 60 | if tt.expectedError { 61 | assert.Error(t, err) 62 | } else { 63 | assert.NoError(t, err) 64 | assert.Equal(t, tt.expectedTags, tags) 65 | } 66 | 67 | mockAPI.AssertExpectations(t) 68 | }) 69 | } 70 | } 71 | 72 | func TestCreateEnvironmentTag(t *testing.T) { 73 | tests := []struct { 74 | name string 75 | tagName string 76 | mockID int64 77 | mockError error 78 | expectedID int 79 | expectedError bool 80 | }{ 81 | { 82 | name: "successful creation", 83 | tagName: "prod", 84 | mockID: 1, 85 | mockError: nil, 86 | expectedID: 1, 87 | expectedError: false, 88 | }, 89 | { 90 | name: "api error", 91 | tagName: "dev", 92 | mockID: 0, 93 | mockError: fmt.Errorf("api error"), 94 | expectedID: 0, 95 | expectedError: true, 96 | }, 97 | } 98 | 99 | for _, tt := range tests { 100 | t.Run(tt.name, func(t *testing.T) { 101 | mockAPI := new(MockPortainerAPI) 102 | mockAPI.On("CreateTag", tt.tagName).Return(tt.mockID, tt.mockError) 103 | 104 | client := &PortainerClient{ 105 | cli: mockAPI, 106 | } 107 | 108 | id, err := client.CreateEnvironmentTag(tt.tagName) 109 | 110 | if tt.expectedError { 111 | assert.Error(t, err) 112 | } else { 113 | assert.NoError(t, err) 114 | assert.Equal(t, tt.expectedID, id) 115 | } 116 | 117 | mockAPI.AssertExpectations(t) 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /pkg/portainer/models/environment.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" 5 | "github.com/portainer/portainer-mcp/pkg/portainer/utils" 6 | ) 7 | 8 | type Environment struct { 9 | ID int `json:"id"` 10 | Name string `json:"name"` 11 | Status string `json:"status"` 12 | Type string `json:"type"` 13 | TagIds []int `json:"tag_ids"` 14 | UserAccesses map[int]string `json:"user_accesses"` 15 | TeamAccesses map[int]string `json:"team_accesses"` 16 | } 17 | 18 | // Environment status constants 19 | const ( 20 | EnvironmentStatusActive = "active" 21 | EnvironmentStatusInactive = "inactive" 22 | EnvironmentStatusUnknown = "unknown" 23 | ) 24 | 25 | // Environment type constants 26 | const ( 27 | EnvironmentTypeDockerLocal = "docker-local" 28 | EnvironmentTypeDockerAgent = "docker-agent" 29 | EnvironmentTypeAzureACI = "azure-aci" 30 | EnvironmentTypeDockerEdgeAgent = "docker-edge-agent" 31 | EnvironmentTypeKubernetesLocal = "kubernetes-local" 32 | EnvironmentTypeKubernetesAgent = "kubernetes-agent" 33 | EnvironmentTypeKubernetesEdgeAgent = "kubernetes-edge-agent" 34 | EnvironmentTypeUnknown = "unknown" 35 | ) 36 | 37 | func ConvertEndpointToEnvironment(rawEndpoint *apimodels.PortainereeEndpoint) Environment { 38 | return Environment{ 39 | ID: int(rawEndpoint.ID), 40 | Name: rawEndpoint.Name, 41 | Status: convertEnvironmentStatus(rawEndpoint), 42 | Type: convertEnvironmentType(rawEndpoint), 43 | TagIds: utils.Int64ToIntSlice(rawEndpoint.TagIds), 44 | UserAccesses: convertAccesses(rawEndpoint.UserAccessPolicies), 45 | TeamAccesses: convertAccesses(rawEndpoint.TeamAccessPolicies), 46 | } 47 | } 48 | 49 | func convertEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string { 50 | if rawEndpoint.Type == 4 || rawEndpoint.Type == 7 { 51 | return convertEdgeEnvironmentStatus(rawEndpoint) 52 | } 53 | return convertStandardEnvironmentStatus(rawEndpoint) 54 | } 55 | 56 | func convertStandardEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string { 57 | switch rawEndpoint.Status { 58 | case 1: 59 | return EnvironmentStatusActive 60 | case 2: 61 | return EnvironmentStatusInactive 62 | default: 63 | return EnvironmentStatusUnknown 64 | } 65 | } 66 | 67 | func convertEdgeEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string { 68 | if rawEndpoint.Heartbeat { 69 | return EnvironmentStatusActive 70 | } 71 | return EnvironmentStatusInactive 72 | } 73 | 74 | func convertEnvironmentType(rawEndpoint *apimodels.PortainereeEndpoint) string { 75 | switch rawEndpoint.Type { 76 | case 1: 77 | return EnvironmentTypeDockerLocal 78 | case 2: 79 | return EnvironmentTypeDockerAgent 80 | case 3: 81 | return EnvironmentTypeAzureACI 82 | case 4: 83 | return EnvironmentTypeDockerEdgeAgent 84 | case 5: 85 | return EnvironmentTypeKubernetesLocal 86 | case 6: 87 | return EnvironmentTypeKubernetesAgent 88 | case 7: 89 | return EnvironmentTypeKubernetesEdgeAgent 90 | default: 91 | return EnvironmentTypeUnknown 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pkg/portainer/client/environment.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 | // GetEnvironments retrieves all environments from the Portainer server. 11 | // 12 | // Returns: 13 | // - A slice of Environment objects 14 | // - An error if the operation fails 15 | func (c *PortainerClient) GetEnvironments() ([]models.Environment, error) { 16 | endpoints, err := c.cli.ListEndpoints() 17 | if err != nil { 18 | return nil, fmt.Errorf("failed to list endpoints: %w", err) 19 | } 20 | 21 | environments := make([]models.Environment, len(endpoints)) 22 | for i, endpoint := range endpoints { 23 | environments[i] = models.ConvertEndpointToEnvironment(endpoint) 24 | } 25 | 26 | return environments, nil 27 | } 28 | 29 | // UpdateEnvironmentTags updates the tags associated with an environment. 30 | // 31 | // Parameters: 32 | // - id: The ID of the environment to update 33 | // - tagIds: A slice of tag IDs to associate with the environment 34 | // 35 | // Returns: 36 | // - An error if the operation fails 37 | func (c *PortainerClient) UpdateEnvironmentTags(id int, tagIds []int) error { 38 | tags := utils.IntToInt64Slice(tagIds) 39 | err := c.cli.UpdateEndpoint(int64(id), 40 | &tags, 41 | nil, 42 | nil, 43 | ) 44 | if err != nil { 45 | return fmt.Errorf("failed to update environment tags: %w", err) 46 | } 47 | return nil 48 | } 49 | 50 | // UpdateEnvironmentUserAccesses updates the user access policies of an environment. 51 | // 52 | // Parameters: 53 | // - id: The ID of the environment to update 54 | // - userAccesses: Map of user IDs to their access level 55 | // 56 | // Valid access levels are: 57 | // - environment_administrator 58 | // - helpdesk_user 59 | // - standard_user 60 | // - readonly_user 61 | // - operator_user 62 | // 63 | // Returns: 64 | // - An error if the operation fails 65 | func (c *PortainerClient) UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error { 66 | uac := utils.IntToInt64Map(userAccesses) 67 | err := c.cli.UpdateEndpoint(int64(id), 68 | nil, 69 | &uac, 70 | nil, 71 | ) 72 | if err != nil { 73 | return fmt.Errorf("failed to update environment user accesses: %w", err) 74 | } 75 | return nil 76 | } 77 | 78 | // UpdateEnvironmentTeamAccesses updates the team access policies of an environment. 79 | // 80 | // Parameters: 81 | // - id: The ID of the environment to update 82 | // - teamAccesses: Map of team IDs to their access level 83 | // 84 | // Valid access levels are: 85 | // - environment_administrator 86 | // - helpdesk_user 87 | // - standard_user 88 | // - readonly_user 89 | // - operator_user 90 | // 91 | // Returns: 92 | // - An error if the operation fails 93 | func (c *PortainerClient) UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error { 94 | tac := utils.IntToInt64Map(teamAccesses) 95 | err := c.cli.UpdateEndpoint(int64(id), 96 | nil, 97 | nil, 98 | &tac, 99 | ) 100 | if err != nil { 101 | return fmt.Errorf("failed to update environment team accesses: %w", err) 102 | } 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/portainer/models/tag_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/portainer/client-api-go/v2/pkg/models" 7 | ) 8 | 9 | func TestConvertTagToEnvironmentTag(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | portainerTag *models.PortainerTag 13 | want EnvironmentTag 14 | }{ 15 | { 16 | name: "basic tag conversion", 17 | portainerTag: &models.PortainerTag{ 18 | ID: 1, 19 | Name: "Production", 20 | Endpoints: map[string]bool{ 21 | "1": true, 22 | "2": true, 23 | "3": true, 24 | }, 25 | }, 26 | want: EnvironmentTag{ 27 | ID: 1, 28 | Name: "Production", 29 | EnvironmentIds: []int{1, 2, 3}, 30 | }, 31 | }, 32 | { 33 | name: "tag with no endpoints", 34 | portainerTag: &models.PortainerTag{ 35 | ID: 2, 36 | Name: "Empty Tag", 37 | Endpoints: map[string]bool{}, 38 | }, 39 | want: EnvironmentTag{ 40 | ID: 2, 41 | Name: "Empty Tag", 42 | EnvironmentIds: []int{}, 43 | }, 44 | }, 45 | { 46 | name: "tag with invalid endpoint ID", 47 | portainerTag: &models.PortainerTag{ 48 | ID: 3, 49 | Name: "Mixed IDs", 50 | Endpoints: map[string]bool{ 51 | "42": true, 52 | "abc": true, // Invalid ID, should be skipped 53 | "99": true, 54 | "invalid": true, // Invalid ID, should be skipped 55 | }, 56 | }, 57 | want: EnvironmentTag{ 58 | ID: 3, 59 | Name: "Mixed IDs", 60 | EnvironmentIds: []int{42, 99}, 61 | }, 62 | }, 63 | { 64 | name: "tag with single endpoint", 65 | portainerTag: &models.PortainerTag{ 66 | ID: 4, 67 | Name: "Single Server", 68 | Endpoints: map[string]bool{ 69 | "5": true, 70 | }, 71 | }, 72 | want: EnvironmentTag{ 73 | ID: 4, 74 | Name: "Single Server", 75 | EnvironmentIds: []int{5}, 76 | }, 77 | }, 78 | } 79 | 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | got := ConvertTagToEnvironmentTag(tt.portainerTag) 83 | 84 | // Since the order of EnvironmentIds is not guaranteed due to map iteration, 85 | // we need to sort both slices before comparison 86 | if !compareEnvironmentTags(got, tt.want) { 87 | t.Errorf("ConvertTagToEnvironmentTag() = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | // compareEnvironmentTags compares two EnvironmentTag structs, handling the 94 | // unordered nature of the EnvironmentIds slice 95 | func compareEnvironmentTags(a, b EnvironmentTag) bool { 96 | if a.ID != b.ID || a.Name != b.Name || len(a.EnvironmentIds) != len(b.EnvironmentIds) { 97 | return false 98 | } 99 | 100 | // Create maps to check if all IDs exist in both slices 101 | aMap := make(map[int]bool) 102 | bMap := make(map[int]bool) 103 | 104 | for _, id := range a.EnvironmentIds { 105 | aMap[id] = true 106 | } 107 | 108 | for _, id := range b.EnvironmentIds { 109 | bMap[id] = true 110 | if !aMap[id] { 111 | return false 112 | } 113 | } 114 | 115 | // Check if all IDs in a exist in b 116 | for id := range aMap { 117 | if !bMap[id] { 118 | return false 119 | } 120 | } 121 | 122 | return true 123 | } 124 | -------------------------------------------------------------------------------- /pkg/portainer/client/team.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/portainer/portainer-mcp/pkg/portainer/models" 7 | ) 8 | 9 | // GetTeams retrieves all teams from the Portainer server. 10 | // 11 | // Returns: 12 | // - A slice of Team objects containing team information 13 | // - An error if the operation fails 14 | func (c *PortainerClient) GetTeams() ([]models.Team, error) { 15 | portainerTeams, err := c.cli.ListTeams() 16 | if err != nil { 17 | return nil, fmt.Errorf("failed to list teams: %w", err) 18 | } 19 | 20 | // Get team memberships to populate team members 21 | memberships, err := c.cli.ListTeamMemberships() 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to list team memberships: %w", err) 24 | } 25 | 26 | teams := make([]models.Team, len(portainerTeams)) 27 | for i, team := range portainerTeams { 28 | teams[i] = models.ConvertToTeam(team, memberships) 29 | } 30 | 31 | return teams, nil 32 | } 33 | 34 | // UpdateTeamName updates the name of a team. 35 | // 36 | // Parameters: 37 | // - id: The ID of the team to update 38 | // - name: The new name for the team 39 | func (c *PortainerClient) UpdateTeamName(id int, name string) error { 40 | return c.cli.UpdateTeamName(id, name) 41 | } 42 | 43 | // CreateTeam creates a new team. 44 | // 45 | // Parameters: 46 | // - name: The name of the team 47 | // 48 | // Returns: 49 | // - The ID of the created team 50 | // - An error if the operation fails 51 | func (c *PortainerClient) CreateTeam(name string) (int, error) { 52 | id, err := c.cli.CreateTeam(name) 53 | if err != nil { 54 | return 0, fmt.Errorf("failed to create team: %w", err) 55 | } 56 | 57 | return int(id), nil 58 | } 59 | 60 | // UpdateTeamMembers updates the members of a team. 61 | // 62 | // Parameters: 63 | // - teamId: The ID of the team to update 64 | // - userIds: The IDs of the users associated with the team 65 | func (c *PortainerClient) UpdateTeamMembers(teamId int, userIds []int) error { 66 | memberships, err := c.cli.ListTeamMemberships() 67 | if err != nil { 68 | return fmt.Errorf("failed to list team memberships: %w", err) 69 | } 70 | 71 | // Track which users are already members of the team 72 | existingMembers := make(map[int]bool) 73 | 74 | // First, handle existing memberships 75 | for _, membership := range memberships { 76 | if membership.TeamID == int64(teamId) { 77 | userID := membership.UserID 78 | existingMembers[int(userID)] = true 79 | 80 | // Check if this user should remain in the team 81 | shouldKeep := false 82 | for _, id := range userIds { 83 | if id == int(userID) { 84 | shouldKeep = true 85 | break 86 | } 87 | } 88 | 89 | // If user should not remain in the team, delete the membership 90 | if !shouldKeep { 91 | if err := c.cli.DeleteTeamMembership(int(membership.ID)); err != nil { 92 | return fmt.Errorf("failed to delete team membership for user %d: %w", userID, err) 93 | } 94 | } 95 | } 96 | } 97 | 98 | // Then, create memberships for new users 99 | for _, userID := range userIds { 100 | // Skip if user is already a member 101 | if existingMembers[userID] { 102 | continue 103 | } 104 | 105 | // Create new membership for this user 106 | if err := c.cli.CreateTeamMembership(teamId, userID); err != nil { 107 | return fmt.Errorf("failed to create team membership for user %d: %w", userID, err) 108 | } 109 | } 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /internal/mcp/team.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) AddTeamFeatures() { 14 | s.addToolIfExists(ToolListTeams, s.HandleGetTeams()) 15 | 16 | if !s.readOnly { 17 | s.addToolIfExists(ToolCreateTeam, s.HandleCreateTeam()) 18 | s.addToolIfExists(ToolUpdateTeamName, s.HandleUpdateTeamName()) 19 | s.addToolIfExists(ToolUpdateTeamMembers, s.HandleUpdateTeamMembers()) 20 | } 21 | } 22 | 23 | func (s *PortainerMCPServer) HandleCreateTeam() server.ToolHandlerFunc { 24 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 25 | parser := toolgen.NewParameterParser(request) 26 | 27 | name, err := parser.GetString("name", true) 28 | if err != nil { 29 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil 30 | } 31 | 32 | teamID, err := s.cli.CreateTeam(name) 33 | if err != nil { 34 | return mcp.NewToolResultErrorFromErr("failed to create team", err), nil 35 | } 36 | 37 | return mcp.NewToolResultText(fmt.Sprintf("Team created successfully with ID: %d", teamID)), nil 38 | } 39 | } 40 | 41 | func (s *PortainerMCPServer) HandleGetTeams() server.ToolHandlerFunc { 42 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 43 | teams, err := s.cli.GetTeams() 44 | if err != nil { 45 | return mcp.NewToolResultErrorFromErr("failed to get teams", err), nil 46 | } 47 | 48 | data, err := json.Marshal(teams) 49 | if err != nil { 50 | return mcp.NewToolResultErrorFromErr("failed to marshal teams", err), nil 51 | } 52 | 53 | return mcp.NewToolResultText(string(data)), nil 54 | } 55 | } 56 | 57 | func (s *PortainerMCPServer) HandleUpdateTeamName() server.ToolHandlerFunc { 58 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 59 | parser := toolgen.NewParameterParser(request) 60 | 61 | id, err := parser.GetInt("id", true) 62 | if err != nil { 63 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil 64 | } 65 | 66 | name, err := parser.GetString("name", true) 67 | if err != nil { 68 | return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil 69 | } 70 | 71 | err = s.cli.UpdateTeamName(id, name) 72 | if err != nil { 73 | return mcp.NewToolResultErrorFromErr("failed to update team name", err), nil 74 | } 75 | 76 | return mcp.NewToolResultText("Team name updated successfully"), nil 77 | } 78 | } 79 | 80 | func (s *PortainerMCPServer) HandleUpdateTeamMembers() server.ToolHandlerFunc { 81 | return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 82 | parser := toolgen.NewParameterParser(request) 83 | 84 | id, err := parser.GetInt("id", true) 85 | if err != nil { 86 | return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil 87 | } 88 | 89 | userIDs, err := parser.GetArrayOfIntegers("userIds", true) 90 | if err != nil { 91 | return mcp.NewToolResultErrorFromErr("invalid userIds parameter", err), nil 92 | } 93 | 94 | err = s.cli.UpdateTeamMembers(id, userIDs) 95 | if err != nil { 96 | return mcp.NewToolResultErrorFromErr("failed to update team members", err), nil 97 | } 98 | 99 | return mcp.NewToolResultText("Team members updated successfully"), nil 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/portainer/client/docker_test.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 TestProxyDockerRequest(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | environmentId int 20 | opts models.DockerProxyRequestOptions 21 | mockResponse *http.Response 22 | mockError error 23 | expectedError bool 24 | expectedStatus int 25 | expectedRespBody string 26 | }{ 27 | { 28 | name: "GET request with query parameters", 29 | opts: models.DockerProxyRequestOptions{ 30 | EnvironmentID: 1, 31 | Method: "GET", 32 | Path: "/images/json", 33 | QueryParams: map[string]string{"all": "true", "filter": "dangling"}, 34 | }, 35 | mockResponse: &http.Response{ 36 | StatusCode: http.StatusOK, 37 | Body: io.NopCloser(strings.NewReader(`[{"Id":"img1"}]`)), 38 | }, 39 | mockError: nil, 40 | expectedError: false, 41 | expectedStatus: http.StatusOK, 42 | expectedRespBody: `[{"Id":"img1"}]`, 43 | }, 44 | { 45 | name: "POST request with custom headers", 46 | opts: models.DockerProxyRequestOptions{ 47 | EnvironmentID: 2, 48 | Method: "POST", 49 | Path: "/networks/create", 50 | Headers: map[string]string{"X-Custom-Header": "value1", "Authorization": "Bearer token"}, 51 | Body: bytes.NewBufferString(`{"Name": "my-network"}`), 52 | }, 53 | mockResponse: &http.Response{ 54 | StatusCode: http.StatusCreated, 55 | Body: io.NopCloser(strings.NewReader(`{"Id": "net1"}`)), 56 | }, 57 | mockError: nil, 58 | expectedError: false, 59 | expectedStatus: http.StatusCreated, 60 | expectedRespBody: `{"Id": "net1"}`, 61 | }, 62 | { 63 | name: "API error", 64 | opts: models.DockerProxyRequestOptions{ 65 | EnvironmentID: 3, 66 | Method: "GET", 67 | Path: "/version", 68 | }, 69 | mockResponse: nil, 70 | mockError: errors.New("failed to proxy request"), 71 | expectedError: true, 72 | expectedStatus: 0, // Not applicable 73 | expectedRespBody: "", // Not applicable 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | mockAPI := new(MockPortainerAPI) 80 | opts := client.ProxyRequestOptions{ 81 | Method: tt.opts.Method, 82 | APIPath: tt.opts.Path, 83 | QueryParams: tt.opts.QueryParams, 84 | Headers: tt.opts.Headers, 85 | Body: tt.opts.Body, 86 | } 87 | mockAPI.On("ProxyDockerRequest", tt.opts.EnvironmentID, opts).Return(tt.mockResponse, tt.mockError) 88 | 89 | client := &PortainerClient{cli: mockAPI} 90 | 91 | resp, err := client.ProxyDockerRequest(tt.opts) 92 | if tt.expectedError { 93 | assert.Error(t, err) 94 | assert.EqualError(t, err, tt.mockError.Error()) 95 | assert.Nil(t, resp) 96 | } else { 97 | assert.NoError(t, err) 98 | assert.NotNil(t, resp) 99 | assert.Equal(t, tt.expectedStatus, resp.StatusCode) 100 | 101 | // Read and verify the response body 102 | if assert.NotNil(t, resp.Body) { // Ensure body is not nil before reading 103 | defer resp.Body.Close() 104 | bodyBytes, readErr := io.ReadAll(resp.Body) 105 | assert.NoError(t, readErr) 106 | assert.Equal(t, tt.expectedRespBody, string(bodyBytes)) 107 | } 108 | } 109 | 110 | mockAPI.AssertExpectations(t) 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/portainer/client/group.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 | // GetEnvironmentGroups retrieves all environment groups from the Portainer server. 11 | // Environment groups are the equivalent of Edge Groups in Portainer. 12 | // 13 | // Returns: 14 | // - A slice of Group objects 15 | // - An error if the operation fails 16 | func (c *PortainerClient) GetEnvironmentGroups() ([]models.Group, error) { 17 | edgeGroups, err := c.cli.ListEdgeGroups() 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to list edge groups: %w", err) 20 | } 21 | 22 | groups := make([]models.Group, len(edgeGroups)) 23 | for i, eg := range edgeGroups { 24 | groups[i] = models.ConvertEdgeGroupToGroup(eg) 25 | } 26 | 27 | return groups, nil 28 | } 29 | 30 | // CreateEnvironmentGroup creates a new environment group on the Portainer server. 31 | // Environment groups are the equivalent of Edge Groups in Portainer. 32 | // Parameters: 33 | // - name: The name of the environment group 34 | // - environmentIds: A slice of environment IDs to include in the group 35 | // 36 | // Returns: 37 | // - The ID of the created environment group 38 | // - An error if the operation fails 39 | func (c *PortainerClient) CreateEnvironmentGroup(name string, environmentIds []int) (int, error) { 40 | id, err := c.cli.CreateEdgeGroup(name, utils.IntToInt64Slice(environmentIds)) 41 | if err != nil { 42 | return 0, fmt.Errorf("failed to create environment group: %w", err) 43 | } 44 | 45 | return int(id), nil 46 | } 47 | 48 | // UpdateEnvironmentGroupName updates the name of an existing environment group. 49 | // Environment groups are the equivalent of Edge Groups in Portainer. 50 | // 51 | // Parameters: 52 | // - id: The ID of the environment group to update 53 | // - name: The new name for the environment group 54 | // 55 | // Returns: 56 | // - An error if the operation fails 57 | func (c *PortainerClient) UpdateEnvironmentGroupName(id int, name string) error { 58 | err := c.cli.UpdateEdgeGroup(int64(id), &name, nil, nil) 59 | if err != nil { 60 | return fmt.Errorf("failed to update environment group name: %w", err) 61 | } 62 | return nil 63 | } 64 | 65 | // UpdateEnvironmentGroupEnvironments updates the environments associated with an environment group. 66 | // Environment groups are the equivalent of Edge Groups in Portainer. 67 | // 68 | // Parameters: 69 | // - id: The ID of the environment group to update 70 | // - environmentIds: A slice of environment IDs to include in the group 71 | // 72 | // Returns: 73 | // - An error if the operation fails 74 | func (c *PortainerClient) UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error { 75 | envs := utils.IntToInt64Slice(environmentIds) 76 | err := c.cli.UpdateEdgeGroup(int64(id), nil, &envs, nil) 77 | if err != nil { 78 | return fmt.Errorf("failed to update environment group environments: %w", err) 79 | } 80 | return nil 81 | } 82 | 83 | // UpdateEnvironmentGroupTags updates the tags associated with an environment group. 84 | // Environment groups are the equivalent of Edge Groups in Portainer. 85 | // 86 | // Parameters: 87 | // - id: The ID of the environment group to update 88 | // - tagIds: A slice of tag IDs to include in the group 89 | // 90 | // Returns: 91 | // - An error if the operation fails 92 | func (c *PortainerClient) UpdateEnvironmentGroupTags(id int, tagIds []int) error { 93 | tags := utils.IntToInt64Slice(tagIds) 94 | err := c.cli.UpdateEdgeGroup(int64(id), nil, nil, &tags) 95 | if err != nil { 96 | return fmt.Errorf("failed to update environment group tags: %w", err) 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/k8sutil/stripper.go: -------------------------------------------------------------------------------- 1 | package k8sutil 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | ) 11 | 12 | // removeManagedFieldsFromUnstructuredObject is a helper function that modifies an Unstructured object in place 13 | // by removing the managedFields attribute from its metadata. 14 | func removeManagedFieldsFromUnstructuredObject(obj *unstructured.Unstructured) error { 15 | if obj == nil || obj.Object == nil { 16 | return nil // Nothing to do 17 | } 18 | 19 | metadata, found, err := unstructured.NestedFieldCopy(obj.Object, "metadata") 20 | if err != nil { 21 | return fmt.Errorf("error fetching metadata for object %s (%s): %w", obj.GetName(), obj.GetKind(), err) 22 | } 23 | if !found { 24 | return nil // Metadata not found, nothing to do 25 | } 26 | 27 | metadataMap, ok := metadata.(map[string]any) 28 | if !ok { 29 | return fmt.Errorf("metadata for object %s (%s) is not in the expected map format", obj.GetName(), obj.GetKind()) 30 | } 31 | 32 | // Delete the managedFields key from the metadata map 33 | delete(metadataMap, "managedFields") 34 | 35 | // TODO: Consider also removing other verbose fields here, e.g., ownerReferences, if needed. 36 | // delete(metadataMap, "ownerReferences") 37 | 38 | // Set the modified metadata back to the object 39 | err = unstructured.SetNestedField(obj.Object, metadataMap, "metadata") 40 | if err != nil { 41 | return fmt.Errorf("error setting modified metadata for object %s (%s): %w", obj.GetName(), obj.GetKind(), err) 42 | } 43 | return nil 44 | } 45 | 46 | // ProcessRawKubernetesAPIResponse takes an HTTP response, processes the JSON body, 47 | // removes managedFields (and potentially other verbose metadata) from any Kubernetes resource(s) found, 48 | // and returns the modified JSON bytes. 49 | func ProcessRawKubernetesAPIResponse(httpResp *http.Response) ([]byte, error) { 50 | if httpResp == nil { 51 | return nil, fmt.Errorf("http response is nil") 52 | } 53 | if httpResp.Body == nil { 54 | if httpResp.StatusCode != http.StatusNoContent && httpResp.ContentLength != 0 { 55 | return nil, fmt.Errorf("http response body is nil but content was expected (status: %s)", httpResp.Status) 56 | } 57 | return []byte{}, nil // Return empty bytes if no body and appropriate status 58 | } 59 | defer httpResp.Body.Close() 60 | 61 | bodyBytes, err := io.ReadAll(httpResp.Body) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to read response body: %w", err) 64 | } 65 | 66 | if len(bodyBytes) == 0 { 67 | return bodyBytes, nil // Valid empty body 68 | } 69 | 70 | uObj := &unstructured.Unstructured{} 71 | if err := uObj.UnmarshalJSON(bodyBytes); err != nil { 72 | trimmedBody := string(bodyBytes) 73 | if trimmedBody == "{}" || trimmedBody == "[]" { 74 | return bodyBytes, nil // Valid empty JSON object/array 75 | } 76 | return nil, fmt.Errorf("failed to unmarshal JSON into Unstructured: %w. Body: %s", err, string(bodyBytes)) 77 | } 78 | 79 | if uObj.IsList() { 80 | list, err := uObj.ToList() 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to convert Unstructured to UnstructuredList: %w", err) 83 | } 84 | 85 | for i := range list.Items { 86 | if err := removeManagedFieldsFromUnstructuredObject(&list.Items[i]); err != nil { 87 | return nil, fmt.Errorf("failed to remove managedFields from item %d in list: %w", i, err) 88 | } 89 | } 90 | return json.Marshal(list) 91 | } else { 92 | if len(uObj.Object) == 0 { 93 | return bodyBytes, nil // Empty object, nothing to process 94 | } 95 | if err := removeManagedFieldsFromUnstructuredObject(uObj); err != nil { 96 | return nil, fmt.Errorf("failed to remove managedFields from single object: %w", err) 97 | } 98 | return json.Marshal(uObj) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/integration/user_test.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: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /internal/mcp/stack.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 | -------------------------------------------------------------------------------- /tests/integration/tag_test.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 | -------------------------------------------------------------------------------- /pkg/toolgen/param.go: -------------------------------------------------------------------------------- 1 | package toolgen 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mark3labs/mcp-go/mcp" 7 | ) 8 | 9 | // ParameterParser provides methods to safely extract parameters from request arguments 10 | type ParameterParser struct { 11 | args map[string]any 12 | } 13 | 14 | // NewParameterParser creates a new parameter parser for the given request 15 | func NewParameterParser(request mcp.CallToolRequest) *ParameterParser { 16 | return &ParameterParser{ 17 | args: request.GetArguments(), 18 | } 19 | } 20 | 21 | // GetString extracts a string parameter from the request 22 | func (p *ParameterParser) GetString(name string, required bool) (string, error) { 23 | value, ok := p.args[name] 24 | if !ok || value == nil { 25 | if required { 26 | return "", fmt.Errorf("%s is required", name) 27 | } 28 | return "", nil 29 | } 30 | 31 | strValue, ok := value.(string) 32 | if !ok { 33 | return "", fmt.Errorf("%s must be a string", name) 34 | } 35 | 36 | return strValue, nil 37 | } 38 | 39 | // GetNumber extracts a number parameter from the request 40 | func (p *ParameterParser) GetNumber(name string, required bool) (float64, error) { 41 | value, ok := p.args[name] 42 | if !ok || value == nil { 43 | if required { 44 | return 0, fmt.Errorf("%s is required", name) 45 | } 46 | return 0, nil 47 | } 48 | 49 | numValue, ok := value.(float64) 50 | if !ok { 51 | return 0, fmt.Errorf("%s must be a number", name) 52 | } 53 | 54 | return numValue, nil 55 | } 56 | 57 | // GetInt extracts an integer parameter from the request 58 | func (p *ParameterParser) GetInt(name string, required bool) (int, error) { 59 | num, err := p.GetNumber(name, required) 60 | if err != nil { 61 | return 0, err 62 | } 63 | return int(num), nil 64 | } 65 | 66 | // GetBoolean extracts a boolean parameter from the request 67 | func (p *ParameterParser) GetBoolean(name string, required bool) (bool, error) { 68 | value, ok := p.args[name] 69 | if !ok || value == nil { 70 | if required { 71 | return false, fmt.Errorf("%s is required", name) 72 | } 73 | return false, nil 74 | } 75 | 76 | boolValue, ok := value.(bool) 77 | if !ok { 78 | return false, fmt.Errorf("%s must be a boolean", name) 79 | } 80 | 81 | return boolValue, nil 82 | } 83 | 84 | // GetArrayOfIntegers extracts an array of numbers parameter from the request 85 | func (p *ParameterParser) GetArrayOfIntegers(name string, required bool) ([]int, error) { 86 | value, ok := p.args[name] 87 | if !ok || value == nil { 88 | if required { 89 | return nil, fmt.Errorf("%s is required", name) 90 | } 91 | return []int{}, nil 92 | } 93 | 94 | arrayValue, ok := value.([]any) 95 | if !ok { 96 | return nil, fmt.Errorf("%s must be an array", name) 97 | } 98 | 99 | return parseArrayOfIntegers(arrayValue) 100 | } 101 | 102 | // GetArrayOfObjects extracts an array of objects parameter from the request 103 | func (p *ParameterParser) GetArrayOfObjects(name string, required bool) ([]any, error) { 104 | value, ok := p.args[name] 105 | if !ok || value == nil { 106 | if required { 107 | return nil, fmt.Errorf("%s is required", name) 108 | } 109 | return []any{}, nil 110 | } 111 | 112 | arrayValue, ok := value.([]any) 113 | if !ok { 114 | return nil, fmt.Errorf("%s must be an array", name) 115 | } 116 | 117 | return arrayValue, nil 118 | } 119 | 120 | // parseArrayOfIntegers converts a slice of any type to a slice of integers. 121 | // Returns an error if any value cannot be parsed as an integer. 122 | // 123 | // Example: 124 | // 125 | // ids, err := parseArrayOfIntegers([]any{1, 2, 3}) 126 | // // ids = []int{1, 2, 3} 127 | func parseArrayOfIntegers(array []any) ([]int, error) { 128 | result := make([]int, 0, len(array)) 129 | 130 | for _, item := range array { 131 | idFloat, ok := item.(float64) 132 | if !ok { 133 | return nil, fmt.Errorf("failed to parse '%v' as integer", item) 134 | } 135 | result = append(result, int(idFloat)) 136 | } 137 | 138 | return result, nil 139 | } 140 | -------------------------------------------------------------------------------- /pkg/portainer/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestInt64ToIntSlice(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | int64s []int64 12 | want []int 13 | }{ 14 | { 15 | name: "empty slice", 16 | int64s: []int64{}, 17 | want: []int{}, 18 | }, 19 | { 20 | name: "single element", 21 | int64s: []int64{42}, 22 | want: []int{42}, 23 | }, 24 | { 25 | name: "multiple elements", 26 | int64s: []int64{1, 2, 3, 4, 5}, 27 | want: []int{1, 2, 3, 4, 5}, 28 | }, 29 | { 30 | name: "large numbers", 31 | int64s: []int64{1000000000, 2000000000}, 32 | want: []int{1000000000, 2000000000}, 33 | }, 34 | { 35 | name: "negative numbers", 36 | int64s: []int64{-1, -10, -100}, 37 | want: []int{-1, -10, -100}, 38 | }, 39 | } 40 | 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | got := Int64ToIntSlice(tt.int64s) 44 | if !reflect.DeepEqual(got, tt.want) { 45 | t.Errorf("Int64ToIntSlice() = %v, want %v", got, tt.want) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func TestIntToInt64Slice(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | ints []int 55 | want []int64 56 | }{ 57 | { 58 | name: "empty slice", 59 | ints: []int{}, 60 | want: []int64{}, 61 | }, 62 | { 63 | name: "single element", 64 | ints: []int{42}, 65 | want: []int64{42}, 66 | }, 67 | { 68 | name: "multiple elements", 69 | ints: []int{1, 2, 3, 4, 5}, 70 | want: []int64{1, 2, 3, 4, 5}, 71 | }, 72 | { 73 | name: "large numbers", 74 | ints: []int{1000000000, 2000000000}, 75 | want: []int64{1000000000, 2000000000}, 76 | }, 77 | { 78 | name: "negative numbers", 79 | ints: []int{-1, -10, -100}, 80 | want: []int64{-1, -10, -100}, 81 | }, 82 | { 83 | name: "max int32 value", 84 | ints: []int{2147483647}, 85 | want: []int64{2147483647}, 86 | }, 87 | } 88 | 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | got := IntToInt64Slice(tt.ints) 92 | if !reflect.DeepEqual(got, tt.want) { 93 | t.Errorf("IntToInt64Slice() = %v, want %v", got, tt.want) 94 | } 95 | }) 96 | } 97 | } 98 | 99 | func TestIntToInt64Map(t *testing.T) { 100 | tests := []struct { 101 | name string 102 | input map[int]string 103 | want map[int64]string 104 | }{ 105 | { 106 | name: "empty map", 107 | input: map[int]string{}, 108 | want: map[int64]string{}, 109 | }, 110 | { 111 | name: "single key-value pair", 112 | input: map[int]string{ 113 | 1: "one", 114 | }, 115 | want: map[int64]string{ 116 | int64(1): "one", 117 | }, 118 | }, 119 | { 120 | name: "multiple key-value pairs", 121 | input: map[int]string{ 122 | 1: "one", 123 | 2: "two", 124 | 3: "three", 125 | }, 126 | want: map[int64]string{ 127 | int64(1): "one", 128 | int64(2): "two", 129 | int64(3): "three", 130 | }, 131 | }, 132 | { 133 | name: "negative keys", 134 | input: map[int]string{ 135 | -1: "minus one", 136 | 0: "zero", 137 | 1: "one", 138 | }, 139 | want: map[int64]string{ 140 | int64(-1): "minus one", 141 | int64(0): "zero", 142 | int64(1): "one", 143 | }, 144 | }, 145 | { 146 | name: "large numbers", 147 | input: map[int]string{ 148 | 1000000: "million", 149 | 9999999: "big number", 150 | }, 151 | want: map[int64]string{ 152 | int64(1000000): "million", 153 | int64(9999999): "big number", 154 | }, 155 | }, 156 | { 157 | name: "empty strings", 158 | input: map[int]string{ 159 | 1: "", 160 | 2: "", 161 | }, 162 | want: map[int64]string{ 163 | int64(1): "", 164 | int64(2): "", 165 | }, 166 | }, 167 | } 168 | 169 | for _, tt := range tests { 170 | t.Run(tt.name, func(t *testing.T) { 171 | got := IntToInt64Map(tt.input) 172 | if !reflect.DeepEqual(got, tt.want) { 173 | t.Errorf("IntToInt64Map() = %v, want %v", got, tt.want) 174 | } 175 | }) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /internal/mcp/schema.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/user_test.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 TestGetUsers(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | mockUsers []*apimodels.PortainereeUser 16 | mockError error 17 | expected []models.User 18 | expectedError bool 19 | }{ 20 | { 21 | name: "successful retrieval - all role types", 22 | mockUsers: []*apimodels.PortainereeUser{ 23 | { 24 | ID: 1, 25 | Username: "admin_user", 26 | Role: 1, // admin 27 | }, 28 | { 29 | ID: 2, 30 | Username: "regular_user", 31 | Role: 2, // user 32 | }, 33 | { 34 | ID: 3, 35 | Username: "edge_admin_user", 36 | Role: 3, // edge_admin 37 | }, 38 | { 39 | ID: 4, 40 | Username: "unknown_role_user", 41 | Role: 0, // unknown 42 | }, 43 | }, 44 | expected: []models.User{ 45 | { 46 | ID: 1, 47 | Username: "admin_user", 48 | Role: models.UserRoleAdmin, 49 | }, 50 | { 51 | ID: 2, 52 | Username: "regular_user", 53 | Role: models.UserRoleUser, 54 | }, 55 | { 56 | ID: 3, 57 | Username: "edge_admin_user", 58 | Role: models.UserRoleEdgeAdmin, 59 | }, 60 | { 61 | ID: 4, 62 | Username: "unknown_role_user", 63 | Role: models.UserRoleUnknown, 64 | }, 65 | }, 66 | }, 67 | { 68 | name: "empty users", 69 | mockUsers: []*apimodels.PortainereeUser{}, 70 | expected: []models.User{}, 71 | }, 72 | { 73 | name: "list error", 74 | mockError: errors.New("failed to list users"), 75 | expectedError: true, 76 | }, 77 | } 78 | 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | mockAPI := new(MockPortainerAPI) 82 | mockAPI.On("ListUsers").Return(tt.mockUsers, tt.mockError) 83 | 84 | client := &PortainerClient{cli: mockAPI} 85 | 86 | users, err := client.GetUsers() 87 | 88 | if tt.expectedError { 89 | assert.Error(t, err) 90 | return 91 | } 92 | assert.NoError(t, err) 93 | assert.Equal(t, tt.expected, users) 94 | mockAPI.AssertExpectations(t) 95 | }) 96 | } 97 | } 98 | 99 | func TestUpdateUserRole(t *testing.T) { 100 | tests := []struct { 101 | name string 102 | userID int 103 | role string 104 | expectedRole int64 105 | mockError error 106 | expectedError bool 107 | }{ 108 | { 109 | name: "update to admin role", 110 | userID: 1, 111 | role: models.UserRoleAdmin, 112 | expectedRole: 1, 113 | }, 114 | { 115 | name: "update to regular user role", 116 | userID: 2, 117 | role: models.UserRoleUser, 118 | expectedRole: 2, 119 | }, 120 | { 121 | name: "update to edge admin role", 122 | userID: 3, 123 | role: models.UserRoleEdgeAdmin, 124 | expectedRole: 3, 125 | }, 126 | { 127 | name: "invalid role", 128 | userID: 4, 129 | role: "invalid_role", 130 | expectedError: true, 131 | }, 132 | { 133 | name: "update error", 134 | userID: 5, 135 | role: models.UserRoleAdmin, 136 | expectedRole: 1, 137 | mockError: errors.New("failed to update user role"), 138 | expectedError: true, 139 | }, 140 | } 141 | 142 | for _, tt := range tests { 143 | t.Run(tt.name, func(t *testing.T) { 144 | mockAPI := new(MockPortainerAPI) 145 | if !tt.expectedError || tt.mockError != nil { 146 | mockAPI.On("UpdateUserRole", tt.userID, tt.expectedRole).Return(tt.mockError) 147 | } 148 | 149 | client := &PortainerClient{cli: mockAPI} 150 | 151 | err := client.UpdateUserRole(tt.userID, tt.role) 152 | 153 | if tt.expectedError { 154 | assert.Error(t, err) 155 | return 156 | } 157 | assert.NoError(t, err) 158 | mockAPI.AssertExpectations(t) 159 | }) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /pkg/portainer/client/settings_test.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 | -------------------------------------------------------------------------------- /internal/mcp/environment.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 | -------------------------------------------------------------------------------- /pkg/portainer/client/kubernetes_test.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: -------------------------------------------------------------------------------- 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/group.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/tag_test.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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/portainer/portainer-mcp 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/docker/docker v28.0.1+incompatible 7 | github.com/docker/go-connections v0.5.0 8 | github.com/go-openapi/runtime v0.28.0 9 | github.com/go-openapi/strfmt v0.23.0 10 | github.com/mark3labs/mcp-go v0.32.0 11 | github.com/portainer/client-api-go/v2 v2.31.2 12 | github.com/rs/zerolog v1.34.0 13 | github.com/stretchr/testify v1.10.0 14 | github.com/testcontainers/testcontainers-go v0.36.0 15 | golang.org/x/mod v0.24.0 16 | gopkg.in/yaml.v3 v3.0.1 17 | k8s.io/apimachinery v0.33.1 18 | ) 19 | 20 | require ( 21 | dario.cat/mergo v1.0.1 // indirect 22 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 23 | github.com/Microsoft/go-winio v0.6.2 // indirect 24 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 25 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 26 | github.com/containerd/log v0.1.0 // indirect 27 | github.com/containerd/platforms v0.2.1 // indirect 28 | github.com/cpuguy83/dockercfg v0.3.2 // indirect 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/distribution/reference v0.6.0 // indirect 31 | github.com/docker/go-units v0.5.0 // indirect 32 | github.com/ebitengine/purego v0.8.2 // indirect 33 | github.com/felixge/httpsnoop v1.0.4 // indirect 34 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 35 | github.com/go-logr/logr v1.4.2 // indirect 36 | github.com/go-logr/stdr v1.2.2 // indirect 37 | github.com/go-ole/go-ole v1.2.6 // indirect 38 | github.com/go-openapi/analysis v0.23.0 // indirect 39 | github.com/go-openapi/errors v0.22.0 // indirect 40 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 41 | github.com/go-openapi/jsonreference v0.21.0 // indirect 42 | github.com/go-openapi/loads v0.22.0 // indirect 43 | github.com/go-openapi/spec v0.21.0 // indirect 44 | github.com/go-openapi/swag v0.23.0 // indirect 45 | github.com/go-openapi/validate v0.24.0 // indirect 46 | github.com/gogo/protobuf v1.3.2 // indirect 47 | github.com/google/uuid v1.6.0 // indirect 48 | github.com/josharian/intern v1.0.0 // indirect 49 | github.com/json-iterator/go v1.1.12 // indirect 50 | github.com/klauspost/compress v1.17.4 // indirect 51 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 52 | github.com/magiconair/properties v1.8.9 // indirect 53 | github.com/mailru/easyjson v0.7.7 // indirect 54 | github.com/mattn/go-colorable v0.1.14 // indirect 55 | github.com/mattn/go-isatty v0.0.20 // indirect 56 | github.com/mitchellh/mapstructure v1.5.0 // indirect 57 | github.com/moby/docker-image-spec v1.3.1 // indirect 58 | github.com/moby/patternmatcher v0.6.0 // indirect 59 | github.com/moby/sys/sequential v0.5.0 // indirect 60 | github.com/moby/sys/user v0.1.0 // indirect 61 | github.com/moby/sys/userns v0.1.0 // indirect 62 | github.com/moby/term v0.5.0 // indirect 63 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 64 | github.com/modern-go/reflect2 v1.0.2 // indirect 65 | github.com/morikuni/aec v1.0.0 // indirect 66 | github.com/oklog/ulid v1.3.1 // indirect 67 | github.com/opencontainers/go-digest v1.0.0 // indirect 68 | github.com/opencontainers/image-spec v1.1.1 // indirect 69 | github.com/opentracing/opentracing-go v1.2.0 // indirect 70 | github.com/pkg/errors v0.9.1 // indirect 71 | github.com/pmezard/go-difflib v1.0.0 // indirect 72 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 73 | github.com/shirou/gopsutil/v4 v4.25.1 // indirect 74 | github.com/sirupsen/logrus v1.9.3 // indirect 75 | github.com/spf13/cast v1.7.1 // indirect 76 | github.com/stretchr/objx v0.5.2 // indirect 77 | github.com/tklauser/go-sysconf v0.3.12 // indirect 78 | github.com/tklauser/numcpus v0.6.1 // indirect 79 | github.com/x448/float16 v0.8.4 // indirect 80 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 81 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 82 | go.mongodb.org/mongo-driver v1.14.0 // indirect 83 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 84 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 85 | go.opentelemetry.io/otel v1.35.0 // indirect 86 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 87 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 88 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 89 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 90 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 91 | golang.org/x/crypto v0.36.0 // indirect 92 | golang.org/x/net v0.38.0 // indirect 93 | golang.org/x/sync v0.12.0 // indirect 94 | golang.org/x/sys v0.31.0 // indirect 95 | golang.org/x/text v0.23.0 // indirect 96 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect 97 | google.golang.org/protobuf v1.36.6 // indirect 98 | gopkg.in/inf.v0 v0.9.1 // indirect 99 | k8s.io/klog/v2 v2.130.1 // indirect 100 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 101 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 102 | sigs.k8s.io/randfill v1.0.0 // indirect 103 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 104 | sigs.k8s.io/yaml v1.4.0 // indirect 105 | 106 | ) 107 | -------------------------------------------------------------------------------- /pkg/portainer/models/settings_test.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 | -------------------------------------------------------------------------------- /tests/integration/server_test.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 | -------------------------------------------------------------------------------- /pkg/toolgen/yaml.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 | -------------------------------------------------------------------------------- /internal/mcp/kubernetes.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 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Portainer MCP Development Guide 2 | 3 | ## Build, Test & Run Commands 4 | - Build: `make build` 5 | - Run tests: `go test -v ./...` 6 | - Run single test: `go test -v ./path/to/package -run TestName` 7 | - Lint: `go vet ./...` and `golint ./...` (install golint if needed) 8 | - Format code: `gofmt -s -w .` 9 | - Run inspector: `make inspector` 10 | - Build for specific platform: `make PLATFORM= ARCH= build` 11 | - Integration tests: `make test-integration` 12 | - Run all tests: `make test-all` 13 | 14 | ## Code Style Guidelines 15 | - Use standard Go naming conventions: PascalCase for exported, camelCase for private 16 | - Follow table-driven test pattern with descriptive test cases 17 | - Error handling: return errors with context via `fmt.Errorf("failed to X: %w", err)` 18 | - Imports: group standard library, external packages, and internal packages 19 | - Function comments: document exported functions with Parameters/Returns sections 20 | - Use functional options pattern for configurable clients 21 | - Package structure: cmd/ for entry points, internal/ for implementation, pkg/ for reusable components 22 | - Models belong in pkg/portainer/models, client implementations in pkg/portainer/client 23 | 24 | ## Design Documentation 25 | - Design decisions are documented in individual files in `docs/design/` directory 26 | - Follow the naming convention: `YYMMDD-N-short-description.md` where: 27 | - `YYMMDD` is the date (year-month-day) 28 | - `N` is a sequence number for that date 29 | - Example: `202505-1-feature-toggles.md` 30 | - Use the standard template structure provided in `docs/design_summary.md` 31 | - Add new decisions to the table in `docs/design_summary.md` 32 | - Review existing decisions before making significant architectural changes 33 | 34 | ## Client and Model Guidelines 35 | 36 | ### Client Structure 37 | 1. **Raw Client** (`portainer/client-api-go/v2`) 38 | - Directly communicates with Portainer API 39 | - Used in integration tests for ground-truth comparisons 40 | - Works with raw models from `github.com/portainer/client-api-go/v2/pkg/models` 41 | 42 | 2. **Wrapper Client** (`pkg/portainer/client`) 43 | - Abstraction layer over the Raw Client 44 | - Simplifies interface for the MCP application 45 | - Handles data transformation between Raw and Local Models 46 | - Used by MCP server handlers 47 | 48 | ### Model Structure 49 | 1. **Raw Models** (`portainer/client-api-go/v2/pkg/models`) 50 | - Direct mapping to Portainer API data structures 51 | - May contain fields not relevant to MCP 52 | - Prefix variables with `raw` (e.g., `rawSettings`, `rawEndpoint`) 53 | 54 | 2. **Local Models** (`pkg/portainer/models`) 55 | - Simplified structures tailored for the MCP application 56 | - Contain only relevant fields with convenient types 57 | - Define conversion functions to transform from Raw Models 58 | 59 | ### Import Conventions 60 | ```go 61 | import ( 62 | "github.com/portainer/portainer-mcp/pkg/portainer/models" // Default: models (Local MCP Models) 63 | apimodels "github.com/portainer/client-api-go/v2/pkg/models" // Alias: apimodels (Raw Client-API-Go Models) 64 | ) 65 | ``` 66 | 67 | ### Testing Approach 68 | - **Unit Tests**: Mock Raw Client interface, verify conversions and expected Local Model output 69 | - **Integration Tests**: Call MCP handler and compare with ground-truth from Raw Client 70 | 71 | ## MCP Server Architecture 72 | 73 | ### Server Configuration 74 | - Server is initialized in `cmd/portainer-mcp/mcp.go` 75 | - Uses functional options pattern via `WithClient()` and `WithReadOnly()` 76 | - Connects to Portainer API using token-based authentication 77 | - Validates compatibility with specific Portainer version 78 | - Loads tool definitions from YAML file 79 | 80 | ### Tool Definitions 81 | - Tools are defined in `internal/tooldef/tools.yaml` 82 | - File is embedded in binary at build time 83 | - External file can override embedded definitions 84 | - Version checking ensures compatibility 85 | - Read-only mode restricts modification capabilities 86 | 87 | ### Handler Pattern 88 | - Each tool has a corresponding handler in `internal/mcp/` 89 | - Handlers follow ToolHandlerFunc signature 90 | - Standard error handling with wrapped errors 91 | - Parameter validation with required flag checks 92 | - Response serialization to JSON 93 | 94 | ## Integration Testing Framework 95 | 96 | ### Test Environment Setup 97 | - Uses Docker containers for Portainer instances 98 | - `tests/integration/helpers/test_env.go` provides test environment utilities 99 | - Creates isolated test environment for each test 100 | - Configures both Raw Client and MCP Server for testing 101 | - Automatically cleans up resources after tests 102 | 103 | ### Testing Conventions 104 | - Tests verify both success and error conditions 105 | - Use table-driven tests with descriptive case names 106 | - Compare MCP handler results with direct API calls 107 | - Validate correct error handling and parameter validation 108 | 109 | ## Version Compatibility 110 | 111 | ### Portainer Version Support 112 | - Each release supports a specific Portainer version (defined in `server.go`) 113 | - Version check at startup prevents compatibility issues 114 | - Fail-fast approach with clear error messaging 115 | 116 | ### Tools File Versioning 117 | - Strict versioning for tools.yaml file 118 | - Version validation at startup 119 | - Clear upgrade path for breaking changes 120 | 121 | ## Security Features 122 | 123 | ### Read-Only Mode 124 | - Flag to enable read-only mode 125 | - Only registers tools that don't modify resources 126 | - Provides protection against accidental modifications 127 | - Safe mode for monitoring and observation 128 | 129 | ### Error Handling 130 | - Validate parameters before performing operations 131 | - Proper error messages with context 132 | - Fail-fast approach for invalid operations -------------------------------------------------------------------------------- /internal/mcp/utils_test.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 | --------------------------------------------------------------------------------