This is page 2 of 4. Use http://codebase.md/portainer/portainer-mcp?page={x} to view the full context.
# Directory Structure
```
├── .cursor
│ └── rules
│ └── integration-test.mdc
├── .github
│ └── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CLAUDE.md
├── cloc.sh
├── cmd
│ ├── portainer-mcp
│ │ └── mcp.go
│ └── token-count
│ └── token.go
├── docs
│ ├── clients_and_models.md
│ ├── design
│ │ ├── 202503-1-external-tools-file.md
│ │ ├── 202503-2-tools-vs-mcp-resources.md
│ │ ├── 202503-3-specific-update-tools.md
│ │ ├── 202504-1-embedded-tools-yaml.md
│ │ ├── 202504-2-tools-yaml-versioning.md
│ │ ├── 202504-3-portainer-version-compatibility.md
│ │ └── 202504-4-read-only-mode.md
│ └── design_summary.md
├── go.mod
├── go.sum
├── internal
│ ├── k8sutil
│ │ ├── stripper_test.go
│ │ └── stripper.go
│ ├── mcp
│ │ ├── access_group_test.go
│ │ ├── access_group.go
│ │ ├── docker_test.go
│ │ ├── docker.go
│ │ ├── environment_test.go
│ │ ├── environment.go
│ │ ├── group_test.go
│ │ ├── group.go
│ │ ├── kubernetes_test.go
│ │ ├── kubernetes.go
│ │ ├── mocks_test.go
│ │ ├── schema_test.go
│ │ ├── schema.go
│ │ ├── server_test.go
│ │ ├── server.go
│ │ ├── settings_test.go
│ │ ├── settings.go
│ │ ├── stack_test.go
│ │ ├── stack.go
│ │ ├── tag_test.go
│ │ ├── tag.go
│ │ ├── team_test.go
│ │ ├── team.go
│ │ ├── testdata
│ │ │ ├── invalid_tools.yaml
│ │ │ └── valid_tools.yaml
│ │ ├── user_test.go
│ │ ├── user.go
│ │ ├── utils_test.go
│ │ └── utils.go
│ └── tooldef
│ ├── tooldef_test.go
│ ├── tooldef.go
│ └── tools.yaml
├── LICENSE
├── Makefile
├── pkg
│ ├── portainer
│ │ ├── client
│ │ │ ├── access_group_test.go
│ │ │ ├── access_group.go
│ │ │ ├── client_test.go
│ │ │ ├── client.go
│ │ │ ├── docker_test.go
│ │ │ ├── docker.go
│ │ │ ├── environment_test.go
│ │ │ ├── environment.go
│ │ │ ├── group_test.go
│ │ │ ├── group.go
│ │ │ ├── kubernetes_test.go
│ │ │ ├── kubernetes.go
│ │ │ ├── mocks_test.go
│ │ │ ├── settings_test.go
│ │ │ ├── settings.go
│ │ │ ├── stack_test.go
│ │ │ ├── stack.go
│ │ │ ├── tag_test.go
│ │ │ ├── tag.go
│ │ │ ├── team_test.go
│ │ │ ├── team.go
│ │ │ ├── user_test.go
│ │ │ ├── user.go
│ │ │ ├── version_test.go
│ │ │ └── version.go
│ │ ├── models
│ │ │ ├── access_group_test.go
│ │ │ ├── access_group.go
│ │ │ ├── access_policy_test.go
│ │ │ ├── access_policy.go
│ │ │ ├── docker.go
│ │ │ ├── environment_test.go
│ │ │ ├── environment.go
│ │ │ ├── group_test.go
│ │ │ ├── group.go
│ │ │ ├── kubernetes.go
│ │ │ ├── settings_test.go
│ │ │ ├── settings.go
│ │ │ ├── stack_test.go
│ │ │ ├── stack.go
│ │ │ ├── tag_test.go
│ │ │ ├── tag.go
│ │ │ ├── team_test.go
│ │ │ ├── team.go
│ │ │ ├── user_test.go
│ │ │ └── user.go
│ │ └── utils
│ │ ├── utils_test.go
│ │ └── utils.go
│ └── toolgen
│ ├── param_test.go
│ ├── param.go
│ ├── yaml_test.go
│ └── yaml.go
├── README.md
├── tests
│ └── integration
│ ├── access_group_test.go
│ ├── containers
│ │ └── portainer.go
│ ├── docker_test.go
│ ├── environment_test.go
│ ├── group_test.go
│ ├── helpers
│ │ └── test_env.go
│ ├── server_test.go
│ ├── settings_test.go
│ ├── stack_test.go
│ ├── tag_test.go
│ ├── team_test.go
│ └── user_test.go
└── token.sh
```
# Files
--------------------------------------------------------------------------------
/internal/mcp/utils_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"reflect"
"testing"
)
func TestParseAccessMap(t *testing.T) {
tests := []struct {
name string
entries []any
want map[int]string
wantErr bool
}{
{
name: "Valid single entry",
entries: []any{
map[string]any{
"id": float64(1),
"access": AccessLevelEnvironmentAdmin,
},
},
want: map[int]string{
1: AccessLevelEnvironmentAdmin,
},
wantErr: false,
},
{
name: "Valid multiple entries",
entries: []any{
map[string]any{
"id": float64(1),
"access": AccessLevelEnvironmentAdmin,
},
map[string]any{
"id": float64(2),
"access": AccessLevelReadonlyUser,
},
},
want: map[int]string{
1: AccessLevelEnvironmentAdmin,
2: AccessLevelReadonlyUser,
},
wantErr: false,
},
{
name: "Invalid entry type",
entries: []any{
"not a map",
},
want: nil,
wantErr: true,
},
{
name: "Invalid ID type",
entries: []any{
map[string]any{
"id": "string-id",
"access": AccessLevelEnvironmentAdmin,
},
},
want: nil,
wantErr: true,
},
{
name: "Invalid access type",
entries: []any{
map[string]any{
"id": float64(1),
"access": 123,
},
},
want: nil,
wantErr: true,
},
{
name: "Invalid access level",
entries: []any{
map[string]any{
"id": float64(1),
"access": "invalid_access_level",
},
},
want: nil,
wantErr: true,
},
{
name: "Empty entries",
entries: []any{},
want: map[int]string{},
wantErr: false,
},
{
name: "Missing ID field",
entries: []any{
map[string]any{
"access": AccessLevelEnvironmentAdmin,
},
},
want: nil,
wantErr: true,
},
{
name: "Missing access field",
entries: []any{
map[string]any{
"id": float64(1),
},
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAccessMap(tt.entries)
if (err != nil) != tt.wantErr {
t.Errorf("parseAccessMap() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseAccessMap() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsValidHTTPMethod(t *testing.T) {
tests := []struct {
name string
method string
expect bool
}{
{"Valid GET", "GET", true},
{"Valid POST", "POST", true},
{"Valid PUT", "PUT", true},
{"Valid DELETE", "DELETE", true},
{"Valid HEAD", "HEAD", true},
{"Invalid lowercase get", "get", false},
{"Invalid PATCH", "PATCH", false},
{"Invalid OPTIONS", "OPTIONS", false},
{"Invalid Empty", "", false},
{"Invalid Random", "RANDOM", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isValidHTTPMethod(tt.method)
if got != tt.expect {
t.Errorf("isValidHTTPMethod(%q) = %v, want %v", tt.method, got, tt.expect)
}
})
}
}
func TestParseKeyValueMap(t *testing.T) {
tests := []struct {
name string
items []any
want map[string]string
wantErr bool
}{
{
name: "Valid single entry",
items: []any{
map[string]any{"key": "k1", "value": "v1"},
},
want: map[string]string{
"k1": "v1",
},
wantErr: false,
},
{
name: "Valid multiple entries",
items: []any{
map[string]any{"key": "k1", "value": "v1"},
map[string]any{"key": "k2", "value": "v2"},
},
want: map[string]string{
"k1": "v1",
"k2": "v2",
},
wantErr: false,
},
{
name: "Empty items",
items: []any{},
want: map[string]string{},
wantErr: false,
},
{
name: "Invalid item type",
items: []any{
"not a map",
},
want: nil,
wantErr: true,
},
{
name: "Invalid key type",
items: []any{
map[string]any{"key": 123, "value": "v1"},
},
want: nil,
wantErr: true,
},
{
name: "Invalid value type",
items: []any{
map[string]any{"key": "k1", "value": 123},
},
want: nil,
wantErr: true,
},
{
name: "Missing key field",
items: []any{
map[string]any{"value": "v1"},
},
want: nil,
wantErr: true,
},
{
name: "Missing value field",
items: []any{
map[string]any{"key": "k1"},
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseKeyValueMap(tt.items)
if (err != nil) != tt.wantErr {
t.Errorf("parseKeyValueMap() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseKeyValueMap() = %v, want %v", got, tt.want)
}
})
}
}
```
--------------------------------------------------------------------------------
/pkg/toolgen/yaml.go:
--------------------------------------------------------------------------------
```go
package toolgen
import (
"fmt"
"log"
"os"
"github.com/mark3labs/mcp-go/mcp"
"golang.org/x/mod/semver"
"gopkg.in/yaml.v3"
)
// ToolsConfig represents the entire YAML configuration
type ToolsConfig struct {
Version string `yaml:"version"`
Tools []ToolDefinition `yaml:"tools"`
}
// ToolDefinition represents a single tool in the YAML config
type ToolDefinition struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Parameters []ParameterDefinition `yaml:"parameters"`
Annotations Annotations `yaml:"annotations"`
}
// ParameterDefinition represents a tool parameter in the YAML config
type ParameterDefinition struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Required bool `yaml:"required"`
Enum []string `yaml:"enum,omitempty"`
Description string `yaml:"description"`
Items map[string]any `yaml:"items,omitempty"`
}
// Annotations represents a tool annotations in the YAML config
type Annotations struct {
Title string `yaml:"title"`
ReadOnlyHint bool `yaml:"readOnlyHint"`
DestructiveHint bool `yaml:"destructiveHint"`
IdempotentHint bool `yaml:"idempotentHint"`
OpenWorldHint bool `yaml:"openWorldHint"`
}
// LoadToolsFromYAML loads tool definitions from a YAML file
// It returns the tools and the version of the tools.yaml file
func LoadToolsFromYAML(filePath string, minimumVersion string) (map[string]mcp.Tool, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
var config ToolsConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
if config.Version == "" {
return nil, fmt.Errorf("missing version in tools.yaml")
}
if !semver.IsValid(config.Version) {
return nil, fmt.Errorf("invalid version in tools.yaml: %s", config.Version)
}
if semver.Compare(config.Version, minimumVersion) < 0 {
return nil, fmt.Errorf("tools.yaml version %s is below the minimum required version %s", config.Version, minimumVersion)
}
return convertToolDefinitions(config.Tools), nil
}
// convertToolDefinitions converts YAML tool definitions to mcp.Tool objects
func convertToolDefinitions(defs []ToolDefinition) map[string]mcp.Tool {
tools := make(map[string]mcp.Tool, len(defs))
for _, def := range defs {
tool, err := convertToolDefinition(def)
if err != nil {
log.Printf("skipping invalid tool definition %s: %s", def.Name, err)
continue
}
tools[def.Name] = tool
}
return tools
}
// convertToolDefinition converts a single YAML tool definition to an mcp.Tool
func convertToolDefinition(def ToolDefinition) (mcp.Tool, error) {
if def.Name == "" {
return mcp.Tool{}, fmt.Errorf("tool name is required")
}
if def.Description == "" {
return mcp.Tool{}, fmt.Errorf("tool description is required for tool '%s'", def.Name)
}
var zeroAnnotations Annotations
if def.Annotations == zeroAnnotations {
return mcp.Tool{}, fmt.Errorf("annotations block is required for tool '%s'", def.Name)
}
options := []mcp.ToolOption{
mcp.WithDescription(def.Description),
}
for _, param := range def.Parameters {
options = append(options, convertParameter(param))
}
options = append(options, convertAnnotation(def.Annotations))
return mcp.NewTool(def.Name, options...), nil
}
// convertAnnotation converts a YAML annotation definition to an mcp option
func convertAnnotation(annotation Annotations) mcp.ToolOption {
return mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: annotation.Title,
ReadOnlyHint: &annotation.ReadOnlyHint,
DestructiveHint: &annotation.DestructiveHint,
IdempotentHint: &annotation.IdempotentHint,
OpenWorldHint: &annotation.OpenWorldHint,
})
}
// convertParameter converts a YAML parameter definition to an mcp option
func convertParameter(param ParameterDefinition) mcp.ToolOption {
var options []mcp.PropertyOption
options = append(options, mcp.Description(param.Description))
if param.Required {
options = append(options, mcp.Required())
}
if param.Enum != nil {
options = append(options, mcp.Enum(param.Enum...))
}
if len(param.Items) > 0 {
options = append(options, mcp.Items(param.Items))
}
switch param.Type {
case "string":
return mcp.WithString(param.Name, options...)
case "number":
return mcp.WithNumber(param.Name, options...)
case "boolean":
return mcp.WithBoolean(param.Name, options...)
case "array":
return mcp.WithArray(param.Name, options...)
case "object":
return mcp.WithObject(param.Name, options...)
default:
// Default to string if type is unknown
return mcp.WithString(param.Name, options...)
}
}
```
--------------------------------------------------------------------------------
/tests/integration/server_test.go:
--------------------------------------------------------------------------------
```go
package integration
import (
"context"
"fmt"
"testing"
mcpmodels "github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/internal/mcp"
"github.com/portainer/portainer-mcp/tests/integration/containers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
toolsPath = "../../internal/tooldef/tools.yaml"
unsupportedImage = "portainer/portainer-ee:2.29.1" // Older version than SupportedPortainerVersion
)
// TestServerInitialization verifies that the Portainer MCP server
// can be successfully initialized with a real Portainer instance.
func TestServerInitialization(t *testing.T) {
// Start a Portainer container
ctx := context.Background()
portainer, err := containers.NewPortainerContainer(ctx)
require.NoError(t, err, "Failed to start Portainer container")
// Ensure container is terminated at the end of the test
defer func() {
if err := portainer.Terminate(ctx); err != nil {
t.Logf("Failed to terminate container: %v", err)
}
}()
// Get the host and port for the Portainer API
host, port := portainer.GetHostAndPort()
serverURL := fmt.Sprintf("%s:%s", host, port)
apiToken := portainer.GetAPIToken()
// Create the MCP server - this is the main test objective
mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath)
// Assert the server was created successfully
require.NoError(t, err, "Failed to create MCP server")
require.NotNil(t, mcpServer, "MCP server should not be nil")
}
// TestServerInitializationUnsupportedVersion verifies that the Portainer MCP server
// correctly rejects initialization with an unsupported Portainer version.
func TestServerInitializationUnsupportedVersion(t *testing.T) {
// Start a Portainer container with unsupported version
ctx := context.Background()
portainer, err := containers.NewPortainerContainer(ctx, containers.WithImage(unsupportedImage))
require.NoError(t, err, "Failed to start unsupported Portainer container")
// Ensure container is terminated at the end of the test
defer func() {
if err := portainer.Terminate(ctx); err != nil {
t.Logf("Failed to terminate container: %v", err)
}
}()
// Get the host and port for the Portainer API
host, port := portainer.GetHostAndPort()
serverURL := fmt.Sprintf("%s:%s", host, port)
apiToken := portainer.GetAPIToken()
// Try to create the MCP server - should fail with version error
mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath)
// Assert the server creation failed with correct error
assert.Error(t, err, "Server creation should fail with unsupported version")
assert.Contains(t, err.Error(), "unsupported Portainer server version", "Error should indicate version mismatch")
assert.Nil(t, mcpServer, "Server should be nil when version check fails")
}
// TestServerInitializationDisabledVersionCheck verifies that the Portainer MCP server
// can successfully connect to unsupported Portainer versions when version check is disabled.
func TestServerInitializationDisabledVersionCheck(t *testing.T) {
// Start a Portainer container with unsupported version
ctx := context.Background()
portainer, err := containers.NewPortainerContainer(ctx, containers.WithImage(unsupportedImage))
require.NoError(t, err, "Failed to start unsupported Portainer container")
// Ensure container is terminated at the end of the test
defer func() {
if err := portainer.Terminate(ctx); err != nil {
t.Logf("Failed to terminate container: %v", err)
}
}()
// Get the host and port for the Portainer API
host, port := portainer.GetHostAndPort()
serverURL := fmt.Sprintf("%s:%s", host, port)
apiToken := portainer.GetAPIToken()
// Create the MCP server with disabled version check - should succeed despite unsupported version
mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath, mcp.WithDisableVersionCheck(true))
// Assert the server was created successfully
require.NoError(t, err, "Failed to create MCP server with disabled version check")
require.NotNil(t, mcpServer, "MCP server should not be nil when version check is disabled")
// Verify basic functionality by testing settings retrieval
handler := mcpServer.HandleGetSettings()
request := mcp.CreateMCPRequest(nil) // GetSettings doesn't require parameters
result, err := handler(ctx, request)
require.NoError(t, err, "Failed to get settings via MCP handler with disabled version check")
require.NotNil(t, result, "Settings result should not be nil")
require.Len(t, result.Content, 1, "Expected exactly one content block in settings result")
// Verify the response contains valid content
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in settings MCP response")
assert.NotEmpty(t, textContent.Text, "Settings response should not be empty")
}
```
--------------------------------------------------------------------------------
/internal/mcp/kubernetes.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"fmt"
"io"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/portainer/portainer-mcp/internal/k8sutil"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/portainer/portainer-mcp/pkg/toolgen"
)
func (s *PortainerMCPServer) AddKubernetesProxyFeatures() {
s.addToolIfExists(ToolKubernetesProxyStripped, s.HandleKubernetesProxyStripped())
if !s.readOnly {
s.addToolIfExists(ToolKubernetesProxy, s.HandleKubernetesProxy())
}
}
func (s *PortainerMCPServer) HandleKubernetesProxyStripped() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
environmentId, err := parser.GetInt("environmentId", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
}
kubernetesAPIPath, err := parser.GetString("kubernetesAPIPath", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid kubernetesAPIPath parameter", err), nil
}
if !strings.HasPrefix(kubernetesAPIPath, "/") {
return mcp.NewToolResultError("kubernetesAPIPath must start with a leading slash"), nil
}
queryParams, err := parser.GetArrayOfObjects("queryParams", false)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
}
queryParamsMap, err := parseKeyValueMap(queryParams)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
}
headers, err := parser.GetArrayOfObjects("headers", false)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
}
headersMap, err := parseKeyValueMap(headers)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
}
opts := models.KubernetesProxyRequestOptions{
EnvironmentID: environmentId,
Path: kubernetesAPIPath,
Method: "GET",
QueryParams: queryParamsMap,
Headers: headersMap,
}
response, err := s.cli.ProxyKubernetesRequest(opts)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to send Kubernetes API request", err), nil
}
responseBody, err := k8sutil.ProcessRawKubernetesAPIResponse(response)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to process Kubernetes API response", err), nil
}
return mcp.NewToolResultText(string(responseBody)), nil
}
}
func (s *PortainerMCPServer) HandleKubernetesProxy() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
environmentId, err := parser.GetInt("environmentId", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
}
method, err := parser.GetString("method", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid method parameter", err), nil
}
if !isValidHTTPMethod(method) {
return mcp.NewToolResultError(fmt.Sprintf("invalid method: %s", method)), nil
}
kubernetesAPIPath, err := parser.GetString("kubernetesAPIPath", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid kubernetesAPIPath parameter", err), nil
}
if !strings.HasPrefix(kubernetesAPIPath, "/") {
return mcp.NewToolResultError("kubernetesAPIPath must start with a leading slash"), nil
}
queryParams, err := parser.GetArrayOfObjects("queryParams", false)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
}
queryParamsMap, err := parseKeyValueMap(queryParams)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
}
headers, err := parser.GetArrayOfObjects("headers", false)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
}
headersMap, err := parseKeyValueMap(headers)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
}
body, err := parser.GetString("body", false)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid body parameter", err), nil
}
opts := models.KubernetesProxyRequestOptions{
EnvironmentID: environmentId,
Path: kubernetesAPIPath,
Method: method,
QueryParams: queryParamsMap,
Headers: headersMap,
}
if body != "" {
opts.Body = strings.NewReader(body)
}
response, err := s.cli.ProxyKubernetesRequest(opts)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to send Kubernetes API request", err), nil
}
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to read Kubernetes API response", err), nil
}
return mcp.NewToolResultText(string(responseBody)), nil
}
}
```
--------------------------------------------------------------------------------
/internal/mcp/server_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"errors"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewPortainerMCPServer(t *testing.T) {
// Define paths to test data files
validToolsPath := "testdata/valid_tools.yaml"
invalidToolsPath := "testdata/invalid_tools.yaml"
tests := []struct {
name string
serverURL string
token string
toolsPath string
mockSetup func(*MockPortainerClient)
expectError bool
errorContains string
}{
{
name: "successful initialization with supported version",
serverURL: "https://portainer.example.com",
token: "valid-token",
toolsPath: validToolsPath,
mockSetup: func(m *MockPortainerClient) {
m.On("GetVersion").Return(SupportedPortainerVersion, nil)
},
expectError: false,
},
{
name: "invalid tools path",
serverURL: "https://portainer.example.com",
token: "valid-token",
toolsPath: "testdata/nonexistent.yaml",
mockSetup: func(m *MockPortainerClient) {},
expectError: true,
errorContains: "failed to load tools",
},
{
name: "invalid tools version",
serverURL: "https://portainer.example.com",
token: "valid-token",
toolsPath: invalidToolsPath,
mockSetup: func(m *MockPortainerClient) {},
expectError: true,
errorContains: "invalid version in tools.yaml",
},
{
name: "API communication error",
serverURL: "https://portainer.example.com",
token: "valid-token",
toolsPath: validToolsPath,
mockSetup: func(m *MockPortainerClient) {
m.On("GetVersion").Return("", errors.New("connection error"))
},
expectError: true,
errorContains: "failed to get Portainer server version",
},
{
name: "unsupported Portainer version",
serverURL: "https://portainer.example.com",
token: "valid-token",
toolsPath: validToolsPath,
mockSetup: func(m *MockPortainerClient) {
m.On("GetVersion").Return("2.0.0", nil)
},
expectError: true,
errorContains: "unsupported Portainer server version",
},
{
name: "unsupported version with disabled version check",
serverURL: "https://portainer.example.com",
token: "valid-token",
toolsPath: validToolsPath,
mockSetup: func(m *MockPortainerClient) {
// No GetVersion call expected when version check is disabled
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create and configure the mock client
mockClient := new(MockPortainerClient)
tt.mockSetup(mockClient)
// Create server with mock client using the WithClient option
var options []ServerOption
options = append(options, WithClient(mockClient))
// Add WithDisableVersionCheck for the specific test case
if tt.name == "unsupported version with disabled version check" {
options = append(options, WithDisableVersionCheck(true))
}
server, err := NewPortainerMCPServer(
tt.serverURL,
tt.token,
tt.toolsPath,
options...,
)
if tt.expectError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
assert.Nil(t, server)
} else {
require.NoError(t, err)
assert.NotNil(t, server)
assert.NotNil(t, server.srv)
assert.NotNil(t, server.cli)
assert.NotNil(t, server.tools)
}
// Verify that all expected methods were called
mockClient.AssertExpectations(t)
})
}
}
func TestAddToolIfExists(t *testing.T) {
tests := []struct {
name string
tools map[string]mcp.Tool
toolName string
exists bool
}{
{
name: "existing tool",
tools: map[string]mcp.Tool{
"test_tool": {
Name: "test_tool",
Description: "Test tool description",
InputSchema: mcp.ToolInputSchema{
Properties: map[string]any{},
},
},
},
toolName: "test_tool",
exists: true,
},
{
name: "non-existing tool",
tools: map[string]mcp.Tool{
"test_tool": {
Name: "test_tool",
Description: "Test tool description",
InputSchema: mcp.ToolInputSchema{
Properties: map[string]any{},
},
},
},
toolName: "nonexistent_tool",
exists: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create server with test tools
mcpServer := server.NewMCPServer(
"Test Server",
"1.0.0",
server.WithResourceCapabilities(true, true),
server.WithLogging(),
)
server := &PortainerMCPServer{
tools: tt.tools,
srv: mcpServer,
}
// Create a handler function
handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{}, nil
}
// Call addToolIfExists
server.addToolIfExists(tt.toolName, handler)
// Verify if the tool exists in the tools map
_, toolExists := server.tools[tt.toolName]
assert.Equal(t, tt.exists, toolExists)
})
}
}
```
--------------------------------------------------------------------------------
/internal/mcp/user_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
)
func TestHandleGetUsers(t *testing.T) {
tests := []struct {
name string
mockUsers []models.User
mockError error
expectError bool
}{
{
name: "successful users retrieval",
mockUsers: []models.User{
{ID: 1, Username: "user1", Role: "admin"},
{ID: 2, Username: "user2", Role: "user"},
},
mockError: nil,
expectError: false,
},
{
name: "api error",
mockUsers: nil,
mockError: fmt.Errorf("api error"),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock client
mockClient := &MockPortainerClient{}
mockClient.On("GetUsers").Return(tt.mockUsers, tt.mockError)
// Create server with mock client
server := &PortainerMCPServer{
cli: mockClient,
}
// Call handler
handler := server.HandleGetUsers()
result, err := handler(context.Background(), mcp.CallToolRequest{})
// Verify results
if tt.expectError {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "result.IsError should be true for API errors")
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok, "Result content should be mcp.TextContent")
if tt.mockError != nil {
assert.Contains(t, textContent.Text, tt.mockError.Error())
}
} else {
assert.NoError(t, err)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok)
var users []models.User
err = json.Unmarshal([]byte(textContent.Text), &users)
assert.NoError(t, err)
assert.Equal(t, tt.mockUsers, users)
}
// Verify mock expectations
mockClient.AssertExpectations(t)
})
}
}
func TestHandleUpdateUserRole(t *testing.T) {
tests := []struct {
name string
inputID int
inputRole string
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful role update",
inputID: 1,
inputRole: "admin",
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"role": "admin",
}
},
},
{
name: "api error",
inputID: 1,
inputRole: "admin",
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"role": "admin",
}
},
},
{
name: "missing id parameter",
inputID: 0,
inputRole: "admin",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"role": "admin",
}
},
},
{
name: "missing role parameter",
inputID: 1,
inputRole: "",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
{
name: "invalid role",
inputID: 1,
inputRole: "invalid_role",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"role": "invalid_role",
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock client
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateUserRole", tt.inputID, tt.inputRole).Return(tt.mockError)
}
// Create server with mock client
server := &PortainerMCPServer{
cli: mockClient,
}
// Create request with parameters
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
// Call handler
handler := server.HandleUpdateUserRole()
result, err := handler(context.Background(), request)
// Verify results
if tt.expectError {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "result.IsError should be true for expected errors")
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok, "Result content should be mcp.TextContent for errors")
if tt.mockError != nil {
assert.Contains(t, textContent.Text, tt.mockError.Error())
} else {
assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
if tt.inputRole == "invalid_role" {
assert.Contains(t, textContent.Text, "invalid role")
}
}
} else {
assert.NoError(t, err)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok)
assert.Contains(t, textContent.Text, "successfully")
}
// Verify mock expectations
mockClient.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/cloc.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# This scripts counts the lines of code (LOC) and comments in Go source files
# within this project directory. It uses the commandline tool "cloc".
# Requires `cloc` to be installed (e.g., `sudo apt install cloc` or `brew install cloc`).
# Modified from: https://schneegans.github.io/tutorials/2022/04/18/badges
#
# Usage:
# Run from the repository root:
# ./cloc.sh
#
# Default Output:
# Displays a summary of code statistics:
# Total lines of code: <value>k
# Lines of source code: <value>k
# Lines of comments (source code): <value>k
# Lines of test code: <value>k
# Comment Percentage: <value>%
# Test Percentage: <value>%
#
# Flags for Specific Metrics:
# You can request individual metrics using the following flags:
# --loc : Lines of source code (Go files, excluding tests).
# --comments : Lines of comments in source code.
# --percentage : Comment percentage in source code.
# --test-loc : Lines of test code (_test.go files + tests/integration/ dir).
# --test-percentage : Percentage of test code compared to total code.
# --total-loc : Total lines of code (source + test).
#
# Example:
# ./cloc.sh --test-percentage
# # Output: 19.0 (example value)
# Get the location of this script.
SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )"
# Run cloc for source code - this counts code lines, blank lines and comment lines
# for the specified languages, excluding test files.
# We are only interested in the summary, therefore the tail -1
SUMMARY_SRC="$(cloc "${SCRIPT_DIR}" --include-lang="Go" --not-match-f="_test\.go$" --not-match-d="tests/integration" --md | tail -1)"
# Run cloc for test files ending in _test.go
SUMMARY_TEST_FILES="$(cloc "${SCRIPT_DIR}" --include-lang="Go" --match-f='_test\.go$' --md | tail -1)"
# Run cloc for the tests/integration directory if it exists
SUMMARY_TEST_DIR=""
if [[ -d "${SCRIPT_DIR}/tests/integration" ]]; then
SUMMARY_TEST_DIR="$(cloc "${SCRIPT_DIR}/tests/integration" --include-lang="Go" --md | tail -1)"
fi
# The SUMMARY strings are lines of a markdown table and look like this:
# SUM:|files|blank|comment|code
# We use the following command to split it into an array.
IFS='|' read -r -a TOKENS_SRC <<< "$SUMMARY_SRC"
IFS='|' read -r -a TOKENS_TEST_FILES <<< "$SUMMARY_TEST_FILES"
IFS='|' read -r -a TOKENS_TEST_DIR <<< "$SUMMARY_TEST_DIR"
# Store the individual tokens for better readability.
# Source Code
NUMBER_OF_FILES_SRC=${TOKENS_SRC[1]:-0} # Default to 0 if empty
COMMENT_LINES_SRC=${TOKENS_SRC[3]:-0}
LINES_OF_CODE_SRC=${TOKENS_SRC[4]:-0}
# Test Code (_test.go files)
LINES_OF_CODE_TEST_FILES=${TOKENS_TEST_FILES[4]:-0}
# Test Code (tests/integration dir)
LINES_OF_CODE_TEST_DIR=${TOKENS_TEST_DIR[4]:-0}
# Total Test Code
LINES_OF_TEST_CODE=$((LINES_OF_CODE_TEST_FILES + LINES_OF_CODE_TEST_DIR))
# Total Code (Source + Test)
TOTAL_LINES_OF_CODE=$((LINES_OF_CODE_SRC + LINES_OF_TEST_CODE))
# Print all results if no arguments are given.
if [[ $# -eq 0 ]] ; then
awk -v loc_src=$LINES_OF_CODE_SRC \
-v comments_src=$COMMENT_LINES_SRC \
-v loc_test=$LINES_OF_TEST_CODE \
-v loc_total=$TOTAL_LINES_OF_CODE \
'BEGIN {
label_width = 35 # Define a width for the labels
printf "%-*s %6.1fk\n", label_width, "Total lines of code:", loc_total/1000;
printf "%-*s %6.1fk\n", label_width, "Lines of source code:", loc_src/1000;
printf "%-*s %6.1fk\n", label_width, "Lines of comments (source code):", comments_src/1000;
printf "%-*s %6.1fk\n", label_width, "Lines of test code:", loc_test/1000;
if (loc_src + comments_src > 0) {
printf "%-*s %6.1f%%\n", label_width, "Comment Percentage:", 100*comments_src/(loc_src + comments_src);
} else {
printf "%-*s %6s\n", label_width, "Comment Percentage:", "N/A"; # Adjusted N/A alignment
}
if (loc_src + loc_test > 0) {
printf "%-*s %6.1f%%\n", label_width, "Test Percentage:", 100*loc_test/(loc_src + loc_test);
} else {
printf "%-*s %6s\n", label_width, "Test Percentage:", "N/A"; # Adjusted N/A alignment
}
}'
exit 0
fi
# --- Argument Parsing ---
# Show lines of source code if --loc is given.
if [[ $* == *--loc* ]]
then
awk -v a=$LINES_OF_CODE_SRC \
'BEGIN {printf "%.1fk\n", a/1000}'
fi
# Show lines of comments if --comments is given.
if [[ $* == *--comments* ]]
then
awk -v a=$COMMENT_LINES_SRC \
'BEGIN {printf "%.1fk\n", a/1000}'
fi
# Show percentage of comments if --percentage is given.
if [[ $* == *--percentage* ]]
then
awk -v a=$COMMENT_LINES_SRC -v b=$LINES_OF_CODE_SRC \
'BEGIN {if (a+b > 0) printf "%.1f\n", 100*a/(a+b); else print "N/A"}'
fi
# Show lines of test code if --test-loc is given.
if [[ $* == *--test-loc* ]]
then
awk -v a=$LINES_OF_TEST_CODE \
'BEGIN {printf "%.1fk\n", a/1000}'
fi
# Show test percentage if --test-percentage is given.
if [[ $* == *--test-percentage* ]]
then
awk -v a=$LINES_OF_TEST_CODE -v b=$LINES_OF_CODE_SRC \
'BEGIN {if (a+b > 0) printf "%.1f\n", 100*a/(a+b); else print "N/A"}'
fi
# Show total lines of code if --total-loc is given.
if [[ $* == *--total-loc* ]]
then
awk -v a=$TOTAL_LINES_OF_CODE \
'BEGIN {printf "%.1fk\n", a/1000}'
fi
```
--------------------------------------------------------------------------------
/pkg/portainer/client/stack_test.go:
--------------------------------------------------------------------------------
```go
package client
import (
"errors"
"testing"
"time"
apimodels "github.com/portainer/client-api-go/v2/pkg/models"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/portainer/portainer-mcp/pkg/portainer/utils"
"github.com/stretchr/testify/assert"
)
func TestGetStacks(t *testing.T) {
now := time.Now().Unix()
tests := []struct {
name string
mockStacks []*apimodels.PortainereeEdgeStack
mockError error
expected []models.Stack
expectedError bool
}{
{
name: "successful retrieval",
mockStacks: []*apimodels.PortainereeEdgeStack{
{
ID: 1,
Name: "stack1",
CreationDate: now,
EdgeGroups: []int64{1, 2},
},
{
ID: 2,
Name: "stack2",
CreationDate: now,
EdgeGroups: []int64{3},
},
},
expected: []models.Stack{
{
ID: 1,
Name: "stack1",
CreatedAt: time.Unix(now, 0).Format(time.RFC3339),
EnvironmentGroupIds: []int{1, 2},
},
{
ID: 2,
Name: "stack2",
CreatedAt: time.Unix(now, 0).Format(time.RFC3339),
EnvironmentGroupIds: []int{3},
},
},
},
{
name: "empty stacks",
mockStacks: []*apimodels.PortainereeEdgeStack{},
expected: []models.Stack{},
},
{
name: "list error",
mockError: errors.New("failed to list stacks"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("ListEdgeStacks").Return(tt.mockStacks, tt.mockError)
client := &PortainerClient{cli: mockAPI}
stacks, err := client.GetStacks()
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, stacks)
mockAPI.AssertExpectations(t)
})
}
}
func TestGetStackFile(t *testing.T) {
tests := []struct {
name string
stackID int
mockFile string
mockError error
expected string
expectedError bool
}{
{
name: "successful retrieval",
stackID: 1,
mockFile: "version: '3'\nservices:\n web:\n image: nginx",
expected: "version: '3'\nservices:\n web:\n image: nginx",
},
{
name: "get file error",
stackID: 2,
mockError: errors.New("failed to get stack file"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("GetEdgeStackFile", int64(tt.stackID)).Return(tt.mockFile, tt.mockError)
client := &PortainerClient{cli: mockAPI}
file, err := client.GetStackFile(tt.stackID)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, file)
mockAPI.AssertExpectations(t)
})
}
}
func TestCreateStack(t *testing.T) {
tests := []struct {
name string
stackName string
stackFile string
environmentGroupIds []int
mockID int64
mockError error
expected int
expectedError bool
}{
{
name: "successful creation",
stackName: "test-stack",
stackFile: "version: '3'\nservices:\n web:\n image: nginx",
environmentGroupIds: []int{1, 2},
mockID: 1,
expected: 1,
},
{
name: "create error",
stackName: "test-stack",
stackFile: "version: '3'\nservices:\n web:\n image: nginx",
environmentGroupIds: []int{1},
mockError: errors.New("failed to create stack"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("CreateEdgeStack", tt.stackName, tt.stackFile, utils.IntToInt64Slice(tt.environmentGroupIds)).Return(tt.mockID, tt.mockError)
client := &PortainerClient{cli: mockAPI}
id, err := client.CreateStack(tt.stackName, tt.stackFile, tt.environmentGroupIds)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, id)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateStack(t *testing.T) {
tests := []struct {
name string
stackID int
stackFile string
environmentGroupIds []int
mockError error
expectedError bool
}{
{
name: "successful update",
stackID: 1,
stackFile: "version: '3'\nservices:\n web:\n image: nginx:latest",
environmentGroupIds: []int{1, 2},
},
{
name: "update error",
stackID: 2,
stackFile: "version: '3'\nservices:\n web:\n image: nginx:latest",
environmentGroupIds: []int{1},
mockError: errors.New("failed to update stack"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEdgeStack", int64(tt.stackID), tt.stackFile, utils.IntToInt64Slice(tt.environmentGroupIds)).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateStack(tt.stackID, tt.stackFile, tt.environmentGroupIds)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/pkg/portainer/models/environment_test.go:
--------------------------------------------------------------------------------
```go
package models
import (
"reflect"
"testing"
"github.com/portainer/client-api-go/v2/pkg/models"
)
func TestConvertEndpointToEnvironment(t *testing.T) {
tests := []struct {
name string
endpoint *models.PortainereeEndpoint
want Environment
}{
{
name: "active docker-local environment with accesses",
endpoint: &models.PortainereeEndpoint{
ID: 1,
Name: "local-docker",
Status: 1, // active
Type: 1, // docker-local
TagIds: []int64{1, 2},
UserAccessPolicies: models.PortainerUserAccessPolicies{
"1": models.PortainerAccessPolicy{RoleID: 1},
"2": models.PortainerAccessPolicy{RoleID: 3},
},
TeamAccessPolicies: models.PortainerTeamAccessPolicies{
"10": models.PortainerAccessPolicy{RoleID: 2},
"20": models.PortainerAccessPolicy{RoleID: 4},
},
},
want: Environment{
ID: 1,
Name: "local-docker",
Status: EnvironmentStatusActive,
Type: EnvironmentTypeDockerLocal,
TagIds: []int{1, 2},
UserAccesses: map[int]string{
1: "environment_administrator",
2: "standard_user",
},
TeamAccesses: map[int]string{
10: "helpdesk_user",
20: "readonly_user",
},
},
},
{
name: "inactive kubernetes-agent environment with empty accesses",
endpoint: &models.PortainereeEndpoint{
ID: 2,
Name: "k8s-agent",
Status: 2, // inactive
Type: 7, // kubernetes-edge-agent
TagIds: []int64{1},
UserAccessPolicies: models.PortainerUserAccessPolicies{},
TeamAccessPolicies: models.PortainerTeamAccessPolicies{},
},
want: Environment{
ID: 2,
Name: "k8s-agent",
Status: EnvironmentStatusInactive,
Type: EnvironmentTypeKubernetesEdgeAgent,
TagIds: []int{1},
UserAccesses: map[int]string{},
TeamAccesses: map[int]string{},
},
},
{
name: "environment with invalid access IDs",
endpoint: &models.PortainereeEndpoint{
ID: 3,
Name: "invalid-access",
Status: 1,
Type: 1,
TagIds: []int64{},
UserAccessPolicies: models.PortainerUserAccessPolicies{
"invalid": models.PortainerAccessPolicy{RoleID: 1},
"2": models.PortainerAccessPolicy{RoleID: 3},
},
TeamAccessPolicies: models.PortainerTeamAccessPolicies{
"bad": models.PortainerAccessPolicy{RoleID: 2},
"20": models.PortainerAccessPolicy{RoleID: 4},
},
},
want: Environment{
ID: 3,
Name: "invalid-access",
Status: EnvironmentStatusActive,
Type: EnvironmentTypeDockerLocal,
TagIds: []int{},
UserAccesses: map[int]string{
2: "standard_user",
},
TeamAccesses: map[int]string{
20: "readonly_user",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ConvertEndpointToEnvironment(tt.endpoint)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ConvertEndpointToEnvironment() = %v, want %v", got, tt.want)
}
})
}
}
func TestConvertEnvironmentStatus(t *testing.T) {
tests := []struct {
name string
endpoint *models.PortainereeEndpoint
want string
}{
{
name: "standard environment - active status",
endpoint: &models.PortainereeEndpoint{
Status: 1,
Type: 1, // docker-local
},
want: EnvironmentStatusActive,
},
{
name: "standard environment - inactive status",
endpoint: &models.PortainereeEndpoint{
Status: 2,
Type: 2, // docker-agent
},
want: EnvironmentStatusInactive,
},
{
name: "standard environment - unknown status",
endpoint: &models.PortainereeEndpoint{
Status: 0,
Type: 3, // azure-aci
},
want: EnvironmentStatusUnknown,
},
{
name: "edge environment - active with heartbeat",
endpoint: &models.PortainereeEndpoint{
Type: 4, // docker-edge-agent
Heartbeat: true,
},
want: EnvironmentStatusActive,
},
{
name: "edge environment - inactive without heartbeat",
endpoint: &models.PortainereeEndpoint{
Type: 7, // kubernetes-edge-agent
Heartbeat: false,
},
want: EnvironmentStatusInactive,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertEnvironmentStatus(tt.endpoint)
if got != tt.want {
t.Errorf("convertEnvironmentStatus() = %v, want %v", got, tt.want)
}
})
}
}
func TestConvertEnvironmentType(t *testing.T) {
tests := []struct {
name string
typeValue int
want string
}{
{
name: "docker-local type",
typeValue: 1,
want: EnvironmentTypeDockerLocal,
},
{
name: "docker-agent type",
typeValue: 2,
want: EnvironmentTypeDockerAgent,
},
{
name: "azure-aci type",
typeValue: 3,
want: EnvironmentTypeAzureACI,
},
{
name: "docker-edge-agent type",
typeValue: 4,
want: EnvironmentTypeDockerEdgeAgent,
},
{
name: "kubernetes-local type",
typeValue: 5,
want: EnvironmentTypeKubernetesLocal,
},
{
name: "kubernetes-agent type",
typeValue: 6,
want: EnvironmentTypeKubernetesAgent,
},
{
name: "kubernetes-edge-agent type",
typeValue: 7,
want: EnvironmentTypeKubernetesEdgeAgent,
},
{
name: "unknown type",
typeValue: 0,
want: EnvironmentTypeUnknown,
},
{
name: "invalid type",
typeValue: 99,
want: EnvironmentTypeUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
endpoint := &models.PortainereeEndpoint{Type: int64(tt.typeValue)}
got := convertEnvironmentType(endpoint)
if got != tt.want {
t.Errorf("convertEnvironmentType() = %v, want %v", got, tt.want)
}
})
}
}
```
--------------------------------------------------------------------------------
/pkg/portainer/client/group_test.go:
--------------------------------------------------------------------------------
```go
package client
import (
"errors"
"testing"
apimodels "github.com/portainer/client-api-go/v2/pkg/models"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestGetEnvironmentGroups(t *testing.T) {
tests := []struct {
name string
mockGroups []*apimodels.EdgegroupsDecoratedEdgeGroup
mockError error
expected []models.Group
expectedError bool
}{
{
name: "successful retrieval",
mockGroups: []*apimodels.EdgegroupsDecoratedEdgeGroup{
{
ID: 1,
Name: "group1",
Endpoints: []int64{1, 2},
TagIds: []int64{1, 2},
},
{
ID: 2,
Name: "group2",
Endpoints: []int64{3},
TagIds: []int64{3},
},
},
expected: []models.Group{
{
ID: 1,
Name: "group1",
EnvironmentIds: []int{1, 2},
TagIds: []int{1, 2},
},
{
ID: 2,
Name: "group2",
EnvironmentIds: []int{3},
TagIds: []int{3},
},
},
},
{
name: "empty groups",
mockGroups: []*apimodels.EdgegroupsDecoratedEdgeGroup{},
expected: []models.Group{},
},
{
name: "list error",
mockError: errors.New("failed to list edge groups"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("ListEdgeGroups").Return(tt.mockGroups, tt.mockError)
client := &PortainerClient{cli: mockAPI}
groups, err := client.GetEnvironmentGroups()
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, groups)
mockAPI.AssertExpectations(t)
})
}
}
func TestCreateEnvironmentGroup(t *testing.T) {
tests := []struct {
name string
groupName string
environmentIds []int
mockID int64
mockError error
expectedID int
expectedError bool
}{
{
name: "successful creation",
groupName: "new-group",
environmentIds: []int{1, 2, 3},
mockID: 1,
expectedID: 1,
},
{
name: "creation error",
groupName: "error-group",
environmentIds: []int{1},
mockError: errors.New("failed to create group"),
expectedError: true,
},
{
name: "empty environments",
groupName: "empty-group",
environmentIds: []int{},
mockID: 2,
expectedID: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("CreateEdgeGroup", tt.groupName, mock.Anything).Return(tt.mockID, tt.mockError)
client := &PortainerClient{cli: mockAPI}
id, err := client.CreateEnvironmentGroup(tt.groupName, tt.environmentIds)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expectedID, id)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateEnvironmentGroupName(t *testing.T) {
tests := []struct {
name string
groupID int
newName string
mockError error
expectedError bool
}{
{
name: "successful update",
groupID: 1,
newName: "updated-group",
},
{
name: "update error",
groupID: 1,
newName: "error-group",
mockError: errors.New("failed to update group name"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), &tt.newName, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateEnvironmentGroupName(tt.groupID, tt.newName)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateEnvironmentGroupEnvironments(t *testing.T) {
tests := []struct {
name string
groupID int
environmentIds []int
mockError error
expectedError bool
}{
{
name: "successful update",
groupID: 1,
environmentIds: []int{1, 2, 3},
},
{
name: "update error",
groupID: 1,
environmentIds: []int{1},
mockError: errors.New("failed to update group environments"),
expectedError: true,
},
{
name: "empty environments",
groupID: 1,
environmentIds: []int{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateEnvironmentGroupEnvironments(tt.groupID, tt.environmentIds)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateEnvironmentGroupTags(t *testing.T) {
tests := []struct {
name string
groupID int
tagIds []int
mockError error
expectedError bool
}{
{
name: "successful update",
groupID: 1,
tagIds: []int{1, 2, 3},
},
{
name: "update error",
groupID: 1,
tagIds: []int{1},
mockError: errors.New("failed to update group tags"),
expectedError: true,
},
{
name: "empty tags",
groupID: 1,
tagIds: []int{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateEnvironmentGroupTags(tt.groupID, tt.tagIds)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/tests/integration/containers/portainer.go:
--------------------------------------------------------------------------------
```go
package containers
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/go-openapi/runtime"
httptransport "github.com/go-openapi/runtime/client"
"github.com/go-openapi/strfmt"
"github.com/portainer/client-api-go/v2/pkg/client"
"github.com/portainer/client-api-go/v2/pkg/client/auth"
"github.com/portainer/client-api-go/v2/pkg/client/users"
"github.com/portainer/client-api-go/v2/pkg/models"
"github.com/portainer/portainer-mcp/internal/mcp"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
defaultPortainerImage = "portainer/portainer-ee:" + mcp.SupportedPortainerVersion
defaultAPIPortTCP = "9443/tcp"
adminPassword = "$2y$05$CiHrhW6R6whDVlu7Wdgl0eccb3rg1NWl/mMiO93vQiRIF1SHNFRsS" // Bcrypt hash of "adminpassword123"
// Timeout for the container to start and be ready to use
startupTimeout = time.Second * 5
)
// PortainerContainer represents a Portainer container for testing
type PortainerContainer struct {
testcontainers.Container
APIPort nat.Port
APIHost string
apiToken string
}
// portainerContainerConfig holds the configuration for creating a Portainer container
type portainerContainerConfig struct {
Image string
BindDockerSocket bool
}
// PortainerContainerOption defines a function type for applying options to Portainer container configuration
type PortainerContainerOption func(*portainerContainerConfig)
// WithImage sets a custom Portainer image
func WithImage(image string) PortainerContainerOption {
return func(cfg *portainerContainerConfig) {
cfg.Image = image
}
}
// WithDockerSocketBind configures the container to bind mount the Docker socket
func WithDockerSocketBind(bind bool) PortainerContainerOption {
return func(cfg *portainerContainerConfig) {
cfg.BindDockerSocket = bind
}
}
// NewPortainerContainer creates and starts a new Portainer container with the specified options
func NewPortainerContainer(ctx context.Context, opts ...PortainerContainerOption) (*PortainerContainer, error) {
// Default configuration
cfg := &portainerContainerConfig{
Image: defaultPortainerImage,
BindDockerSocket: false,
}
// Apply provided options
for _, opt := range opts {
opt(cfg)
}
// Container request configuration
req := testcontainers.ContainerRequest{
Image: cfg.Image,
ExposedPorts: []string{defaultAPIPortTCP},
WaitingFor: wait.ForAll(
// Wait for the HTTPS server to start
wait.ForLog("starting HTTPS server").
WithStartupTimeout(startupTimeout),
// Then wait for the API to be responsive
wait.ForHTTP("/api/system/status").
WithTLS(true, nil).
WithAllowInsecure(true).
WithPort(defaultAPIPortTCP).
WithStatusCodeMatcher(
func(status int) bool {
return status == http.StatusOK
},
).
WithStartupTimeout(startupTimeout),
),
Cmd: []string{
"--admin-password",
adminPassword,
"--log-level",
"DEBUG",
},
HostConfigModifier: func(hostConfig *container.HostConfig) {
if cfg.BindDockerSocket {
hostConfig.Binds = append(hostConfig.Binds, "/var/run/docker.sock:/var/run/docker.sock")
}
},
}
// Create and start the container
cntr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start Portainer container: %w", err)
}
// Get the host and port mapping
host, err := cntr.Host(ctx)
if err != nil {
cntr.Terminate(ctx) // Clean up if we fail post-start
return nil, fmt.Errorf("failed to get container host: %w", err)
}
mappedPort, err := cntr.MappedPort(ctx, nat.Port(defaultAPIPortTCP))
if err != nil {
cntr.Terminate(ctx) // Clean up if we fail post-start
return nil, fmt.Errorf("failed to get mapped port: %w", err)
}
pc := &PortainerContainer{
Container: cntr,
APIPort: mappedPort,
APIHost: host,
}
// Register API token after successful container start and port mapping
if err := pc.registerAPIToken(); err != nil {
// Attempt to clean up the container if token registration fails
cntr.Terminate(ctx)
return nil, fmt.Errorf("failed to register API token: %w", err)
}
return pc, nil
}
// GetAPIBaseURL returns the base URL for the Portainer API
func (pc *PortainerContainer) GetAPIBaseURL() string {
return fmt.Sprintf("https://%s:%s", pc.APIHost, pc.APIPort.Port())
}
// GetHostAndPort returns the host and port for the Portainer API
func (pc *PortainerContainer) GetHostAndPort() (string, string) {
return pc.APIHost, pc.APIPort.Port()
}
func (pc *PortainerContainer) GetAPIToken() string {
return pc.apiToken
}
// registerAPIToken registers an API token for the admin user
func (pc *PortainerContainer) registerAPIToken() error {
transport := httptransport.New(
fmt.Sprintf("%s:%s", pc.APIHost, pc.APIPort.Port()),
"/api",
[]string{"https"},
)
transport.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
portainerClient := client.New(transport, strfmt.Default)
username := "admin"
password := "adminpassword123"
params := auth.NewAuthenticateUserParams().WithBody(&models.AuthAuthenticatePayload{
Username: &username,
Password: &password,
})
authResp, err := portainerClient.Auth.AuthenticateUser(params)
if err != nil {
return fmt.Errorf("failed to authenticate user: %w", err)
}
token := authResp.Payload.Jwt
// Setup JWT authentication
jwtAuth := runtime.ClientAuthInfoWriterFunc(func(r runtime.ClientRequest, _ strfmt.Registry) error {
return r.SetHeaderParam("Authorization", fmt.Sprintf("Bearer %s", token))
})
transport.DefaultAuthentication = jwtAuth
description := "test-api-key"
createTokenParams := users.NewUserGenerateAPIKeyParams().WithID(1).WithBody(&models.UsersUserAccessTokenCreatePayload{
Description: &description,
Password: &password,
})
createTokenResp, err := portainerClient.Users.UserGenerateAPIKey(createTokenParams, nil)
if err != nil {
return fmt.Errorf("failed to generate API key: %w", err)
}
pc.apiToken = createTokenResp.Payload.RawAPIKey
return nil
}
```
--------------------------------------------------------------------------------
/tests/integration/stack_test.go:
--------------------------------------------------------------------------------
```go
package integration
import (
"encoding/json"
"fmt"
"testing"
mcpmodels "github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/internal/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/portainer/portainer-mcp/tests/integration/helpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testStackName = "test-mcp-stack"
testStackFile = "version: '3'\nservices:\n web:\n image: nginx:latest"
testStackFileUpdated = "version: '3'\nservices:\n web:\n image: nginx:alpine"
testEdgeGroupName = "test-stack-group"
)
// prepareStackManagementTestEnvironment creates a test environment group needed for stack tests
func prepareStackManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) int {
// First, enable Edge features in Portainer
host, port := env.Portainer.GetHostAndPort()
serverAddr := fmt.Sprintf("%s:%s", host, port)
tunnelAddr := fmt.Sprintf("%s:8000", host)
err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
require.NoError(t, err, "Failed to update settings to enable Edge features")
// Create a test environment group for the stack to be associated with
testGroupID, err := env.RawClient.CreateEdgeGroup(testEdgeGroupName, []int64{})
require.NoError(t, err, "Failed to create test environment group via raw client")
return int(testGroupID)
}
// TestStackManagement is an integration test suite that verifies the complete
// lifecycle of stack management in Portainer MCP. It tests stack creation,
// retrieval, file content retrieval, and updates.
func TestStackManagement(t *testing.T) {
env := helpers.NewTestEnv(t)
defer env.Cleanup(t)
// Prepare the test environment
testGroupID := prepareStackManagementTestEnvironment(t, env)
var testStackID int
// Subtest: Stack Creation
// Verifies that:
// - A new stack can be created via the MCP handler
// - The handler response indicates success with an ID
// - The created stack exists in Portainer when checked directly via Raw Client
t.Run("Stack Creation", func(t *testing.T) {
handler := env.MCPServer.HandleCreateStack()
request := mcp.CreateMCPRequest(map[string]any{
"name": testStackName,
"file": testStackFile,
"environmentGroupIds": []any{float64(testGroupID)},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to create stack via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
// Check for success message and extract ID for later tests
assert.Contains(t, textContent.Text, "Stack created successfully with ID:", "Success message prefix mismatch")
// Verify by fetching stacks directly via client and finding the created stack by name
stack, err := env.RawClient.GetEdgeStackByName(testStackName)
require.NoError(t, err, "Failed to get stack directly via client after creation")
assert.Equal(t, testStackName, stack.Name, "Stack name mismatch")
// Extract stack ID for subsequent tests
testStackID = int(stack.ID)
})
// Subtest: Stack Listing
// Verifies that:
// - The stack list can be retrieved via the MCP handler
// - The list contains the expected stack
// - The stack data matches the expected properties
t.Run("Stack Listing", func(t *testing.T) {
handler := env.MCPServer.HandleGetStacks()
result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
require.NoError(t, err, "Failed to get stacks via MCP handler")
assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
assert.True(t, ok, "Expected text content in MCP response")
var retrievedStacks []models.Stack
err = json.Unmarshal([]byte(textContent.Text), &retrievedStacks)
require.NoError(t, err, "Failed to unmarshal retrieved stacks")
require.Len(t, retrievedStacks, 1, "Expected exactly one stack after unmarshalling")
stack := retrievedStacks[0]
assert.Equal(t, testStackName, stack.Name, "Stack name mismatch")
// Fetch the same stack directly via the client
rawStack, err := env.RawClient.GetEdgeStack(int64(testStackID))
require.NoError(t, err, "Failed to get stack directly via client")
// Convert the raw stack to the expected Stack model
expectedStack := models.ConvertEdgeStackToStack(rawStack)
assert.Equal(t, expectedStack, stack, "Stack mismatch between MCP handler and direct client call")
})
// Subtest: Get Stack File
// Verifies that:
// - The stack file can be retrieved via the MCP handler
// - The file content matches the content used during creation
t.Run("Get Stack File", func(t *testing.T) {
handler := env.MCPServer.HandleGetStackFile()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testStackID),
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to get stack file via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
// Compare with the original content
assert.Equal(t, testStackFile, textContent.Text, "Stack file content mismatch")
})
// Subtest: Stack Update
// Verifies that:
// - A stack can be updated via the MCP handler
// - The handler response indicates success
// - The stack file is updated when checked directly via Raw Client
t.Run("Stack Update", func(t *testing.T) {
handler := env.MCPServer.HandleUpdateStack()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testStackID),
"file": testStackFileUpdated,
"environmentGroupIds": []any{float64(testGroupID)},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update stack via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
assert.Contains(t, textContent.Text, "Stack updated successfully", "Success message mismatch")
// Verify by fetching stack file directly via raw client
updatedFile, err := env.RawClient.GetEdgeStackFile(int64(testStackID))
require.NoError(t, err, "Failed to get stack file via raw client after update")
assert.Equal(t, testStackFileUpdated, updatedFile, "Stack file was not updated correctly")
})
}
```
--------------------------------------------------------------------------------
/internal/mcp/access_group.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"encoding/json"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/portainer/portainer-mcp/pkg/toolgen"
)
func (s *PortainerMCPServer) AddAccessGroupFeatures() {
s.addToolIfExists(ToolListAccessGroups, s.HandleGetAccessGroups())
if !s.readOnly {
s.addToolIfExists(ToolCreateAccessGroup, s.HandleCreateAccessGroup())
s.addToolIfExists(ToolUpdateAccessGroupName, s.HandleUpdateAccessGroupName())
s.addToolIfExists(ToolUpdateAccessGroupUserAccesses, s.HandleUpdateAccessGroupUserAccesses())
s.addToolIfExists(ToolUpdateAccessGroupTeamAccesses, s.HandleUpdateAccessGroupTeamAccesses())
s.addToolIfExists(ToolAddEnvironmentToAccessGroup, s.HandleAddEnvironmentToAccessGroup())
s.addToolIfExists(ToolRemoveEnvironmentFromAccessGroup, s.HandleRemoveEnvironmentFromAccessGroup())
}
}
func (s *PortainerMCPServer) HandleGetAccessGroups() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
accessGroups, err := s.cli.GetAccessGroups()
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to get access groups", err), nil
}
data, err := json.Marshal(accessGroups)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to marshal access groups", err), nil
}
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *PortainerMCPServer) HandleCreateAccessGroup() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
name, err := parser.GetString("name", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
}
environmentIds, err := parser.GetArrayOfIntegers("environmentIds", false)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
}
groupID, err := s.cli.CreateAccessGroup(name, environmentIds)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to create access group", err), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Access group created successfully with ID: %d", groupID)), nil
}
}
func (s *PortainerMCPServer) HandleUpdateAccessGroupName() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
id, err := parser.GetInt("id", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
}
name, err := parser.GetString("name", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
}
err = s.cli.UpdateAccessGroupName(id, name)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to update access group name", err), nil
}
return mcp.NewToolResultText("Access group name updated successfully"), nil
}
}
func (s *PortainerMCPServer) HandleUpdateAccessGroupUserAccesses() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
id, err := parser.GetInt("id", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
}
userAccesses, err := parser.GetArrayOfObjects("userAccesses", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid userAccesses parameter", err), nil
}
userAccessesMap, err := parseAccessMap(userAccesses)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid user accesses", err), nil
}
err = s.cli.UpdateAccessGroupUserAccesses(id, userAccessesMap)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to update access group user accesses", err), nil
}
return mcp.NewToolResultText("Access group user accesses updated successfully"), nil
}
}
func (s *PortainerMCPServer) HandleUpdateAccessGroupTeamAccesses() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
id, err := parser.GetInt("id", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
}
teamAccesses, err := parser.GetArrayOfObjects("teamAccesses", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid teamAccesses parameter", err), nil
}
teamAccessesMap, err := parseAccessMap(teamAccesses)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid team accesses", err), nil
}
err = s.cli.UpdateAccessGroupTeamAccesses(id, teamAccessesMap)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to update access group team accesses", err), nil
}
return mcp.NewToolResultText("Access group team accesses updated successfully"), nil
}
}
func (s *PortainerMCPServer) HandleAddEnvironmentToAccessGroup() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
id, err := parser.GetInt("id", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
}
environmentId, err := parser.GetInt("environmentId", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
}
err = s.cli.AddEnvironmentToAccessGroup(id, environmentId)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to add environment to access group", err), nil
}
return mcp.NewToolResultText("Environment added to access group successfully"), nil
}
}
func (s *PortainerMCPServer) HandleRemoveEnvironmentFromAccessGroup() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
parser := toolgen.NewParameterParser(request)
id, err := parser.GetInt("id", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
}
environmentId, err := parser.GetInt("environmentId", true)
if err != nil {
return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
}
err = s.cli.RemoveEnvironmentFromAccessGroup(id, environmentId)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to remove environment from access group", err), nil
}
return mcp.NewToolResultText("Environment removed from access group successfully"), nil
}
}
```
--------------------------------------------------------------------------------
/internal/mcp/server.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"fmt"
"log"
"net/http"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/portainer/portainer-mcp/pkg/portainer/client"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/portainer/portainer-mcp/pkg/toolgen"
)
const (
// MinimumToolsVersion is the minimum supported version of the tools.yaml file
MinimumToolsVersion = "1.0"
// SupportedPortainerVersion is the version of Portainer that is supported by this tool
SupportedPortainerVersion = "2.31.2"
)
// PortainerClient defines the interface for the wrapper client used by the MCP server
type PortainerClient interface {
// Tag methods
GetEnvironmentTags() ([]models.EnvironmentTag, error)
CreateEnvironmentTag(name string) (int, error)
// Environment methods
GetEnvironments() ([]models.Environment, error)
UpdateEnvironmentTags(id int, tagIds []int) error
UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error
UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error
// Environment Group methods
GetEnvironmentGroups() ([]models.Group, error)
CreateEnvironmentGroup(name string, environmentIds []int) (int, error)
UpdateEnvironmentGroupName(id int, name string) error
UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error
UpdateEnvironmentGroupTags(id int, tagIds []int) error
// Access Group methods
GetAccessGroups() ([]models.AccessGroup, error)
CreateAccessGroup(name string, environmentIds []int) (int, error)
UpdateAccessGroupName(id int, name string) error
UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error
UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error
AddEnvironmentToAccessGroup(id int, environmentId int) error
RemoveEnvironmentFromAccessGroup(id int, environmentId int) error
// Stack methods
GetStacks() ([]models.Stack, error)
GetStackFile(id int) (string, error)
CreateStack(name string, file string, environmentGroupIds []int) (int, error)
UpdateStack(id int, file string, environmentGroupIds []int) error
// Team methods
CreateTeam(name string) (int, error)
GetTeams() ([]models.Team, error)
UpdateTeamName(id int, name string) error
UpdateTeamMembers(id int, userIds []int) error
// User methods
GetUsers() ([]models.User, error)
UpdateUserRole(id int, role string) error
// Settings methods
GetSettings() (models.PortainerSettings, error)
// Version methods
GetVersion() (string, error)
// Docker Proxy methods
ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error)
// Kubernetes Proxy methods
ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error)
}
// PortainerMCPServer is the main server that handles MCP protocol communication
// with AI assistants and translates them into Portainer API calls.
type PortainerMCPServer struct {
srv *server.MCPServer
cli PortainerClient
tools map[string]mcp.Tool
readOnly bool
}
// ServerOption is a function that configures the server
type ServerOption func(*serverOptions)
// serverOptions contains all configurable options for the server
type serverOptions struct {
client PortainerClient
readOnly bool
disableVersionCheck bool
}
// WithClient sets a custom client for the server.
// This is primarily used for testing to inject mock clients.
func WithClient(client PortainerClient) ServerOption {
return func(opts *serverOptions) {
opts.client = client
}
}
// WithReadOnly sets the server to read-only mode.
// This will prevent the server from registering write tools.
func WithReadOnly(readOnly bool) ServerOption {
return func(opts *serverOptions) {
opts.readOnly = readOnly
}
}
// WithDisableVersionCheck disables the Portainer server version check.
// This allows connecting to unsupported Portainer versions.
func WithDisableVersionCheck(disable bool) ServerOption {
return func(opts *serverOptions) {
opts.disableVersionCheck = disable
}
}
// NewPortainerMCPServer creates a new Portainer MCP server.
//
// This server provides an implementation of the MCP protocol for Portainer,
// allowing AI assistants to interact with Portainer through a structured API.
//
// Parameters:
// - serverURL: The base URL of the Portainer server (e.g., "https://portainer.example.com")
// - token: The API token for authenticating with the Portainer server
// - toolsPath: Path to the tools.yaml file that defines the available MCP tools
// - options: Optional functional options for customizing server behavior (e.g., WithClient)
//
// Returns:
// - A configured PortainerMCPServer instance ready to be started
// - An error if initialization fails
//
// Possible errors:
// - Failed to load tools from the specified path
// - Failed to communicate with the Portainer server
// - Incompatible Portainer server version
func NewPortainerMCPServer(serverURL, token, toolsPath string, options ...ServerOption) (*PortainerMCPServer, error) {
opts := &serverOptions{}
for _, option := range options {
option(opts)
}
tools, err := toolgen.LoadToolsFromYAML(toolsPath, MinimumToolsVersion)
if err != nil {
return nil, fmt.Errorf("failed to load tools: %w", err)
}
var portainerClient PortainerClient
if opts.client != nil {
portainerClient = opts.client
} else {
portainerClient = client.NewPortainerClient(serverURL, token, client.WithSkipTLSVerify(true))
}
if !opts.disableVersionCheck {
version, err := portainerClient.GetVersion()
if err != nil {
return nil, fmt.Errorf("failed to get Portainer server version: %w", err)
}
if version != SupportedPortainerVersion {
return nil, fmt.Errorf("unsupported Portainer server version: %s, only version %s is supported", version, SupportedPortainerVersion)
}
}
return &PortainerMCPServer{
srv: server.NewMCPServer(
"Portainer MCP Server",
"0.5.1",
server.WithToolCapabilities(true),
server.WithLogging(),
),
cli: portainerClient,
tools: tools,
readOnly: opts.readOnly,
}, nil
}
// Start begins listening for MCP protocol messages on standard input/output.
// This is a blocking call that will run until the connection is closed.
func (s *PortainerMCPServer) Start() error {
return server.ServeStdio(s.srv)
}
// addToolIfExists adds a tool to the server if it exists in the tools map
func (s *PortainerMCPServer) addToolIfExists(toolName string, handler server.ToolHandlerFunc) {
if tool, exists := s.tools[toolName]; exists {
s.srv.AddTool(tool, handler)
} else {
log.Printf("Tool %s not found, will not be registered for MCP usage", toolName)
}
}
```
--------------------------------------------------------------------------------
/docs/clients_and_models.md:
--------------------------------------------------------------------------------
```markdown
# Portainer MCP Client and Model Usage Guide
This document clarifies the different client implementations and model structures used within the `portainer-mcp` project to prevent confusion and aid development.
## Overview
The project interacts with the Portainer API using two main client layers and involves two primary sets of data models:
1. **Raw Client & Models:** Provided by the `portainer/client-api-go` library.
2. **Wrapper Client & Local Models:** Defined within `portainer-mcp/pkg/portainer/`.
Understanding the distinction and interaction between these layers is crucial.
## Clients
### 1. Raw Client (`portainer/client-api-go/v2`)
* **Package:** `github.com/portainer/client-api-go/v2`
* **Role:** This is the underlying library that directly communicates with the Portainer API.
* **Usage:** It's instantiated within the Wrapper Client. It's also often used directly within **integration tests** (`tests/integration/`) to fetch the ground-truth state from Portainer for comparison against the MCP handler's output.
* **Models Used:** Interacts primarily with the Raw Models defined in `github.com/portainer/client-api-go/v2/pkg/models`.
### 2. Wrapper Client (`portainer-mcp/pkg/portainer/client`)
* **Package:** `github.com/portainer/portainer-mcp/pkg/portainer/client`
* **Role:** This client acts as an **abstraction layer** on top of the Raw Client. Its primary purposes are:
* To simplify the interface exposed to the rest of the `portainer-mcp` application (specifically the MCP server handlers in `internal/mcp/`).
* To perform necessary **data transformations**, converting Raw Models from the API into the simpler, tailored Local Models.
* To encapsulate common logic or error handling related to Portainer API interactions.
* **Usage:** This is the client used by the **MCP server handlers** (`internal/mcp/server.go` instantiates it and passes it to handlers).
* **Models Used:** Takes Raw Models as input from the Raw Client but typically **returns Local Models** (`portainer-mcp/pkg/portainer/models`) after performing conversions.
## Models
### 1. Raw Models (`portainer/client-api-go/v2/pkg/models`)
* **Package:** `github.com/portainer/client-api-go/v2/pkg/models`
* **Role:** These structs directly map to the data structures returned by the Portainer API.
* **Characteristics:** Can be complex, may contain fields not relevant to MCP, and might use types (like numeric enums) that are less convenient for MCP's purposes.
* **Examples:** `models.PortainereeSettings`, `models.PortainereeEndpoint`.
* **Usage:** Returned by the Raw Client, used as input to the conversion functions within the Wrapper Client / Local Models package.
* **Naming Convention:** To improve clarity, variables holding instances of these Raw Models are typically prefixed with `raw` (e.g., `rawSettings`, `rawEndpoint`).
### 2. Local Models (`portainer-mcp/pkg/portainer/models`)
* **Package:** `github.com/portainer/portainer-mcp/pkg/portainer/models`
* **Role:** These are simplified, tailored structs designed specifically for use within the `portainer-mcp` application and for exposure via the MCP tools.
* **Characteristics:** Simpler structure, contain only relevant fields, often use more convenient types (like string enums).
* **Examples:** `models.PortainerSettings`, `models.Environment`, `models.EnvironmentTag`.
* **Usage:** Returned by the Wrapper Client, used within MCP server handlers, and ultimately determine the structure of data returned by MCP tools.
### 3. Conversion Functions
* **Location:** Typically reside within `portainer-mcp/pkg/portainer/models`.
* **Role:** Bridge the gap between Raw Models and Local Models.
* **Examples:** `ConvertSettingsToPortainerSettings`, `ConvertEndpointToEnvironment`.
* **Usage:** Called by the Wrapper Client methods to transform data before returning it. The function parameters accepting Raw Models typically follow the `raw` prefix naming convention (e.g., `func ConvertSettingsToPortainerSettings(rawSettings *apimodels.PortainereeSettings)`).
## Typical Workflow Example (`GetSettings`)
1. **MCP Handler (`internal/mcp/settings.go`)**: Receives a tool call.
2. Calls `s.cli.GetSettings()`. Here, `s.cli` is an instance of the **Wrapper Client** (`PortainerClient`).
3. **Wrapper Client (`pkg/portainer/client/settings.go`)**: Its `GetSettings` method is executed.
4. Calls the **Raw Client**'s `GetSettings` method (e.g., `c.cli.GetSettings()`).
5. Raw Client interacts with the Portainer API and returns a **Raw Model** (`*portainermodels.PortainereeSettings`).
6. Wrapper Client calls the **Conversion Function** (`models.ConvertSettingsToPortainerSettings`) with the Raw Model.
7. Conversion Function returns a **Local Model** (`models.PortainerSettings`).
8. Wrapper Client returns the Local Model to the MCP Handler.
9. MCP Handler marshals the **Local Model** (`models.PortainerSettings`) into JSON and returns it as the tool result.
## Import Conventions
To improve clarity, especially in files where both model types might appear (like tests), consider using consistent import aliases. Leaving the local `portainer-mcp/pkg/portainer/models` package as the default `models` and aliasing the external library is recommended:
```go
import (
"github.com/portainer/portainer-mcp/pkg/portainer/models" // Default: models (Local MCP Models)
apimodels "github.com/portainer/client-api-go/v2/pkg/models" // Alias: apimodels (Raw Client-API-Go Models)
)
```
This approach keeps code cleaner for the more frequently used local models while clearly indicating when the raw API models are involved.
## Testing Implications
* **Unit Tests** (like `pkg/portainer/client/settings_test.go`): Should mock the Raw Client interface and verify that the Wrapper Client correctly calls the Raw Client and performs the necessary conversions, returning the expected Local Model.
* **Integration Tests** (like `tests/integration/settings_test.go`):
* Call the MCP handler, which uses the Wrapper Client internally and returns JSON representing a Local Model.
* Often need to *also* call the Raw Client directly to get the ground-truth state from the live Portainer instance (variables holding this state should follow the `raw` prefix convention, e.g., `rawEndpoint`).
* May need to manually apply the same Conversion Function to the Raw Model obtained from the Raw Client to create an expected Local Model for comparison against the handler's result.
By understanding these distinct layers and their interactions, development and testing within `portainer-mcp` should be clearer.
```
--------------------------------------------------------------------------------
/pkg/portainer/client/environment_test.go:
--------------------------------------------------------------------------------
```go
package client
import (
"errors"
"testing"
apimodels "github.com/portainer/client-api-go/v2/pkg/models"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestGetEnvironments(t *testing.T) {
tests := []struct {
name string
mockEndpoints []*apimodels.PortainereeEndpoint
mockError error
expected []models.Environment
expectedError bool
}{
{
name: "successful retrieval",
mockEndpoints: []*apimodels.PortainereeEndpoint{
{
ID: 1,
Name: "env1",
GroupID: 1,
Status: 1, // active
Type: 1, // docker-local
TagIds: []int64{1, 2},
UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
"1": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
"2": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
"3": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
"4": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
"5": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
},
TeamAccessPolicies: apimodels.PortainerTeamAccessPolicies{
"6": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
"7": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
"8": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
"9": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
"10": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
},
},
{
ID: 2,
Name: "env2",
GroupID: 1,
Status: 2, // inactive
Type: 2, // docker-agent
TagIds: []int64{3},
},
{
ID: 3,
Name: "env3",
Status: 0, // unknown
Type: 0, // unknown
},
},
expected: []models.Environment{
{
ID: 1,
Name: "env1",
Status: "active",
Type: "docker-local",
TagIds: []int{1, 2},
UserAccesses: map[int]string{
1: "environment_administrator",
2: "helpdesk_user",
3: "standard_user",
4: "readonly_user",
5: "operator_user",
},
TeamAccesses: map[int]string{
6: "environment_administrator",
7: "helpdesk_user",
8: "standard_user",
9: "readonly_user",
10: "operator_user",
},
},
{
ID: 2,
Name: "env2",
Status: "inactive",
Type: "docker-agent",
TagIds: []int{3},
UserAccesses: map[int]string{},
TeamAccesses: map[int]string{},
},
{
ID: 3,
Name: "env3",
Status: "unknown",
Type: "unknown",
TagIds: []int{},
UserAccesses: map[int]string{},
TeamAccesses: map[int]string{},
},
},
},
{
name: "empty environments",
mockEndpoints: []*apimodels.PortainereeEndpoint{},
expected: []models.Environment{},
},
{
name: "list error",
mockError: errors.New("failed to list endpoints"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("ListEndpoints").Return(tt.mockEndpoints, tt.mockError)
client := &PortainerClient{cli: mockAPI}
environments, err := client.GetEnvironments()
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, environments)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateEnvironmentTags(t *testing.T) {
tests := []struct {
name string
envID int
tagIds []int
mockError error
expectedError bool
}{
{
name: "successful update",
envID: 1,
tagIds: []int{1, 2, 3},
},
{
name: "update error",
envID: 1,
tagIds: []int{1},
mockError: errors.New("failed to update tags"),
expectedError: true,
},
{
name: "empty tags",
envID: 1,
tagIds: []int{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateEnvironmentTags(tt.envID, tt.tagIds)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateEnvironmentUserAccesses(t *testing.T) {
tests := []struct {
name string
envID int
userAccesses map[int]string
mockError error
expectedError bool
}{
{
name: "successful update",
envID: 1,
userAccesses: map[int]string{
1: "environment_administrator",
2: "helpdesk_user",
3: "standard_user",
4: "readonly_user",
5: "operator_user",
},
},
{
name: "update error",
envID: 1,
userAccesses: map[int]string{
1: "environment_administrator",
},
mockError: errors.New("failed to update user accesses"),
expectedError: true,
},
{
name: "empty accesses",
envID: 1,
userAccesses: map[int]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateEnvironmentUserAccesses(tt.envID, tt.userAccesses)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateEnvironmentTeamAccesses(t *testing.T) {
tests := []struct {
name string
envID int
teamAccesses map[int]string
mockError error
expectedError bool
}{
{
name: "successful update",
envID: 1,
teamAccesses: map[int]string{
1: "environment_administrator",
2: "helpdesk_user",
3: "standard_user",
4: "readonly_user",
5: "operator_user",
},
},
{
name: "update error",
envID: 1,
teamAccesses: map[int]string{
1: "environment_administrator",
},
mockError: errors.New("failed to update team accesses"),
expectedError: true,
},
{
name: "empty accesses",
envID: 1,
teamAccesses: map[int]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateEnvironmentTeamAccesses(tt.envID, tt.teamAccesses)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/tests/integration/team_test.go:
--------------------------------------------------------------------------------
```go
package integration
import (
"encoding/json"
"testing"
mcpmodels "github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/internal/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/portainer/portainer-mcp/tests/integration/helpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testTeamName = "test-mcp-team"
testTeamNewName = "test-mcp-team-updated"
testUser1Name = "test-team-user1"
testUser2Name = "test-team-user2"
testTeamUserPassword = "testpassword"
teamUserRoleStandard = 2 // Portainer API role ID for Standard User
)
// prepareTeamManagementTestEnvironment creates test users that can be assigned to teams
func prepareTeamManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int) {
testUser1ID, err := env.RawClient.CreateUser(testUser1Name, testTeamUserPassword, teamUserRoleStandard)
require.NoError(t, err, "Failed to create first test user via raw client")
testUser2ID, err := env.RawClient.CreateUser(testUser2Name, testTeamUserPassword, teamUserRoleStandard)
require.NoError(t, err, "Failed to create second test user via raw client")
return int(testUser1ID), int(testUser2ID)
}
// TestTeamManagement is an integration test suite that verifies the complete
// lifecycle of team management in Portainer MCP. It tests team creation,
// listing, name updates, and member management.
func TestTeamManagement(t *testing.T) {
env := helpers.NewTestEnv(t)
defer env.Cleanup(t)
// Prepare the test environment
testUser1ID, testUser2ID := prepareTeamManagementTestEnvironment(t, env)
var testTeamID int
// Subtest: Team Creation
// Verifies that:
// - A new team can be created via the MCP handler.
// - The handler response indicates success with an ID.
// - The created team exists in Portainer when checked directly via the Raw Client.
t.Run("Team Creation", func(t *testing.T) {
handler := env.MCPServer.HandleCreateTeam()
request := mcp.CreateMCPRequest(map[string]any{
"name": testTeamName,
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to create team via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
// Check for success message and extract ID for later tests
assert.Contains(t, textContent.Text, "Team created successfully with ID:", "Success message prefix mismatch")
// Verify by fetching teams directly via client and finding the created team by name
team, err := env.RawClient.GetTeamByName(testTeamName)
require.NoError(t, err, "Failed to get team directly via client after creation")
assert.Equal(t, testTeamName, team.Name, "Team name mismatch")
// Extract team ID for subsequent tests
testTeamID = int(team.ID)
})
// Subtest: Team Listing
// Verifies that:
// - The team list can be retrieved via the MCP handler
// - The list contains the expected number of teams (one, the test team)
// - The team has the correct name property
// - The team data matches the team obtained directly via Raw Client when converted to the same model
t.Run("Team Listing", func(t *testing.T) {
handler := env.MCPServer.HandleGetTeams()
result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
require.NoError(t, err, "Failed to get teams via MCP handler")
assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
assert.True(t, ok, "Expected text content in MCP response")
var retrievedTeams []models.Team
err = json.Unmarshal([]byte(textContent.Text), &retrievedTeams)
require.NoError(t, err, "Failed to unmarshal retrieved teams")
require.Len(t, retrievedTeams, 1, "Expected exactly one team after unmarshalling")
team := retrievedTeams[0]
assert.Equal(t, testTeamName, team.Name, "Team name mismatch")
// Fetch the same team directly via the client
rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
require.NoError(t, err, "Failed to get team directly via client")
// Convert the raw team to the expected Team model
rawMemberships, err := env.RawClient.ListTeamMemberships()
require.NoError(t, err, "Failed to get team memberships directly via client")
expectedTeam := models.ConvertToTeam(rawTeam, rawMemberships)
assert.Equal(t, expectedTeam, team, "Team mismatch between MCP handler and direct client call")
})
// Subtest: Team Name Update
// Verifies that:
// - A team's name can be updated via the MCP handler
// - The handler response indicates success
// - The team name is actually updated when checked directly via Raw Client
t.Run("Team Name Update", func(t *testing.T) {
handler := env.MCPServer.HandleUpdateTeamName()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testTeamID),
"name": testTeamNewName,
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update team name via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response for team name update")
assert.Contains(t, textContent.Text, "Team name updated successfully", "Success message mismatch for team name update")
// Verify by fetching team directly via raw client
rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
require.NoError(t, err, "Failed to get team directly via client after name update")
assert.Equal(t, testTeamNewName, rawTeam.Name, "Team name was not updated")
})
// Subtest: Team Members Update
// Verifies that:
// - Team members can be updated via the MCP handler
// - The handler response indicates success
// - The team memberships are correctly updated when checked directly via Raw Client
// - Both test users are properly assigned to the team
t.Run("Team Members Update", func(t *testing.T) {
handler := env.MCPServer.HandleUpdateTeamMembers()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testTeamID),
"userIds": []any{float64(testUser1ID), float64(testUser2ID)},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update team members via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response for team members update")
assert.Contains(t, textContent.Text, "Team members updated successfully", "Success message mismatch for team members update")
// Verify by fetching team directly via raw client
rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
require.NoError(t, err, "Failed to get team directly via client after member update")
rawMemberships, err := env.RawClient.ListTeamMemberships()
require.NoError(t, err, "Failed to get team memberships directly via client")
expectedTeam := models.ConvertToTeam(rawTeam, rawMemberships)
assert.ElementsMatch(t, []int{testUser1ID, testUser2ID}, expectedTeam.MemberIDs, "Team memberships mismatch")
})
}
```
--------------------------------------------------------------------------------
/internal/mcp/mocks_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"net/http"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/mock"
)
// Mock Implementation Patterns:
//
// This file contains mock implementations of the PortainerClient interface.
// The following patterns are used throughout the mocks:
//
// 1. Methods returning (T, error):
// - Uses m.Called() to record the method call and get mock behavior
// - Includes nil check on first return value to avoid type assertion panics
// - Example:
// func (m *Mock) Method() (T, error) {
// args := m.Called()
// if args.Get(0) == nil {
// return nil, args.Error(1)
// }
// return args.Get(0).(T), args.Error(1)
// }
//
// 2. Methods returning only error:
// - Uses m.Called() with any parameters
// - Returns only the error value
// - Example:
// func (m *Mock) Method(param string) error {
// args := m.Called(param)
// return args.Error(0)
// }
//
// Usage in Tests:
// mock := new(MockPortainerClient)
// mock.On("MethodName").Return(expectedValue, nil)
// result, err := mock.MethodName()
// mock.AssertExpectations(t)
// MockPortainerClient is a mock implementation of the PortainerClient interface
type MockPortainerClient struct {
mock.Mock
}
// Tag methods
func (m *MockPortainerClient) GetEnvironmentTags() ([]models.EnvironmentTag, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]models.EnvironmentTag), args.Error(1)
}
func (m *MockPortainerClient) CreateEnvironmentTag(name string) (int, error) {
args := m.Called(name)
return args.Int(0), args.Error(1)
}
// Environment methods
func (m *MockPortainerClient) GetEnvironments() ([]models.Environment, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]models.Environment), args.Error(1)
}
func (m *MockPortainerClient) UpdateEnvironmentTags(id int, tagIds []int) error {
args := m.Called(id, tagIds)
return args.Error(0)
}
func (m *MockPortainerClient) UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error {
args := m.Called(id, userAccesses)
return args.Error(0)
}
func (m *MockPortainerClient) UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error {
args := m.Called(id, teamAccesses)
return args.Error(0)
}
// Environment Group methods
func (m *MockPortainerClient) GetEnvironmentGroups() ([]models.Group, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]models.Group), args.Error(1)
}
func (m *MockPortainerClient) CreateEnvironmentGroup(name string, environmentIds []int) (int, error) {
args := m.Called(name, environmentIds)
return args.Int(0), args.Error(1)
}
func (m *MockPortainerClient) UpdateEnvironmentGroupName(id int, name string) error {
args := m.Called(id, name)
return args.Error(0)
}
func (m *MockPortainerClient) UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error {
args := m.Called(id, environmentIds)
return args.Error(0)
}
func (m *MockPortainerClient) UpdateEnvironmentGroupTags(id int, tagIds []int) error {
args := m.Called(id, tagIds)
return args.Error(0)
}
// Access Group methods
func (m *MockPortainerClient) GetAccessGroups() ([]models.AccessGroup, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]models.AccessGroup), args.Error(1)
}
func (m *MockPortainerClient) CreateAccessGroup(name string, environmentIds []int) (int, error) {
args := m.Called(name, environmentIds)
return args.Int(0), args.Error(1)
}
func (m *MockPortainerClient) UpdateAccessGroupName(id int, name string) error {
args := m.Called(id, name)
return args.Error(0)
}
func (m *MockPortainerClient) UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error {
args := m.Called(id, userAccesses)
return args.Error(0)
}
func (m *MockPortainerClient) UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error {
args := m.Called(id, teamAccesses)
return args.Error(0)
}
func (m *MockPortainerClient) AddEnvironmentToAccessGroup(id int, environmentId int) error {
args := m.Called(id, environmentId)
return args.Error(0)
}
func (m *MockPortainerClient) RemoveEnvironmentFromAccessGroup(id int, environmentId int) error {
args := m.Called(id, environmentId)
return args.Error(0)
}
// Stack methods
func (m *MockPortainerClient) GetStacks() ([]models.Stack, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]models.Stack), args.Error(1)
}
func (m *MockPortainerClient) GetStackFile(id int) (string, error) {
args := m.Called(id)
return args.String(0), args.Error(1)
}
func (m *MockPortainerClient) CreateStack(name string, file string, environmentGroupIds []int) (int, error) {
args := m.Called(name, file, environmentGroupIds)
return args.Int(0), args.Error(1)
}
func (m *MockPortainerClient) UpdateStack(id int, file string, environmentGroupIds []int) error {
args := m.Called(id, file, environmentGroupIds)
return args.Error(0)
}
// Team methods
func (m *MockPortainerClient) CreateTeam(name string) (int, error) {
args := m.Called(name)
return args.Int(0), args.Error(1)
}
func (m *MockPortainerClient) GetTeams() ([]models.Team, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]models.Team), args.Error(1)
}
func (m *MockPortainerClient) UpdateTeamName(id int, name string) error {
args := m.Called(id, name)
return args.Error(0)
}
func (m *MockPortainerClient) UpdateTeamMembers(id int, userIds []int) error {
args := m.Called(id, userIds)
return args.Error(0)
}
// User methods
func (m *MockPortainerClient) GetUsers() ([]models.User, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]models.User), args.Error(1)
}
func (m *MockPortainerClient) UpdateUserRole(id int, role string) error {
args := m.Called(id, role)
return args.Error(0)
}
// Settings methods
func (m *MockPortainerClient) GetSettings() (models.PortainerSettings, error) {
args := m.Called()
if args.Get(0) == nil {
return models.PortainerSettings{}, args.Error(1)
}
return args.Get(0).(models.PortainerSettings), args.Error(1)
}
func (m *MockPortainerClient) GetVersion() (string, error) {
args := m.Called()
if args.Get(0) == nil {
return "", args.Error(1)
}
return args.Get(0).(string), args.Error(1)
}
// Docker Proxy methods
func (m *MockPortainerClient) ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error) {
args := m.Called(opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*http.Response), args.Error(1)
}
// Kubernetes Proxy methods
func (m *MockPortainerClient) ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error) {
args := m.Called(opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*http.Response), args.Error(1)
}
```
--------------------------------------------------------------------------------
/pkg/portainer/client/team_test.go:
--------------------------------------------------------------------------------
```go
package client
import (
"errors"
"testing"
apimodels "github.com/portainer/client-api-go/v2/pkg/models"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
)
func TestGetTeams(t *testing.T) {
tests := []struct {
name string
mockTeams []*apimodels.PortainerTeam
mockMemberships []*apimodels.PortainerTeamMembership
mockTeamError error
mockMemberError error
expected []models.Team
expectedError bool
}{
{
name: "successful retrieval",
mockTeams: []*apimodels.PortainerTeam{
{
ID: 1,
Name: "team1",
},
{
ID: 2,
Name: "team2",
},
},
mockMemberships: []*apimodels.PortainerTeamMembership{
{
ID: 1,
UserID: 100,
TeamID: 1,
},
{
ID: 2,
UserID: 101,
TeamID: 1,
},
{
ID: 3,
UserID: 102,
TeamID: 2,
},
},
expected: []models.Team{
{
ID: 1,
Name: "team1",
MemberIDs: []int{100, 101},
},
{
ID: 2,
Name: "team2",
MemberIDs: []int{102},
},
},
},
{
name: "teams with empty memberships",
mockTeams: []*apimodels.PortainerTeam{
{
ID: 1,
Name: "team1",
},
{
ID: 2,
Name: "team2",
},
},
mockMemberships: []*apimodels.PortainerTeamMembership{},
expected: []models.Team{
{
ID: 1,
Name: "team1",
MemberIDs: []int{},
},
{
ID: 2,
Name: "team2",
MemberIDs: []int{},
},
},
},
{
name: "empty teams",
mockTeams: []*apimodels.PortainerTeam{},
mockMemberships: []*apimodels.PortainerTeamMembership{},
expected: []models.Team{},
},
{
name: "list teams error",
mockTeamError: errors.New("failed to list teams"),
expectedError: true,
},
{
name: "list memberships error",
mockTeams: []*apimodels.PortainerTeam{
{
ID: 1,
Name: "team1",
},
},
mockMemberError: errors.New("failed to list memberships"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("ListTeams").Return(tt.mockTeams, tt.mockTeamError)
mockAPI.On("ListTeamMemberships").Return(tt.mockMemberships, tt.mockMemberError)
client := &PortainerClient{cli: mockAPI}
teams, err := client.GetTeams()
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, teams)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateTeamName(t *testing.T) {
tests := []struct {
name string
teamID int
teamName string
mockError error
expectedError bool
}{
{
name: "successful update",
teamID: 1,
teamName: "new-team-name",
},
{
name: "update error",
teamID: 2,
teamName: "new-team-name",
mockError: errors.New("failed to update team name"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateTeamName", tt.teamID, tt.teamName).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateTeamName(tt.teamID, tt.teamName)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestCreateTeam(t *testing.T) {
tests := []struct {
name string
teamName string
mockID int64
mockError error
expected int
expectedError bool
}{
{
name: "successful creation",
teamName: "new-team",
mockID: 1,
expected: 1,
},
{
name: "create error",
teamName: "new-team",
mockError: errors.New("failed to create team"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("CreateTeam", tt.teamName).Return(tt.mockID, tt.mockError)
client := &PortainerClient{cli: mockAPI}
id, err := client.CreateTeam(tt.teamName)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, id)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateTeamMembers(t *testing.T) {
tests := []struct {
name string
teamID int
userIDs []int
mockMemberships []*apimodels.PortainerTeamMembership
mockListError error
mockDeleteError error
mockCreateError error
expectedError bool
}{
{
name: "successful update - add and remove members",
teamID: 1,
userIDs: []int{101, 102}, // Want to keep 101 and add 102
mockMemberships: []*apimodels.PortainerTeamMembership{
{
ID: 1,
UserID: 100, // Should be removed
TeamID: 1,
},
{
ID: 2,
UserID: 101, // Should be kept
TeamID: 1,
},
},
},
{
name: "successful update - no changes needed",
teamID: 1,
userIDs: []int{100, 101},
mockMemberships: []*apimodels.PortainerTeamMembership{
{
ID: 1,
UserID: 100,
TeamID: 1,
},
{
ID: 2,
UserID: 101,
TeamID: 1,
},
},
},
{
name: "list memberships error",
teamID: 1,
userIDs: []int{100},
mockListError: errors.New("failed to list memberships"),
expectedError: true,
},
{
name: "delete membership error",
teamID: 1,
userIDs: []int{101}, // Want to remove 100
mockMemberships: []*apimodels.PortainerTeamMembership{
{
ID: 1,
UserID: 100,
TeamID: 1,
},
},
mockDeleteError: errors.New("failed to delete membership"),
expectedError: true,
},
{
name: "create membership error",
teamID: 1,
userIDs: []int{100}, // Want to add 100
mockMemberships: []*apimodels.PortainerTeamMembership{},
mockCreateError: errors.New("failed to create membership"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("ListTeamMemberships").Return(tt.mockMemberships, tt.mockListError)
// Set up delete expectations for memberships that should be removed
for _, membership := range tt.mockMemberships {
shouldDelete := true
for _, keepID := range tt.userIDs {
if int(membership.UserID) == keepID {
shouldDelete = false
break
}
}
if shouldDelete {
mockAPI.On("DeleteTeamMembership", int(membership.ID)).Return(tt.mockDeleteError)
}
}
// Set up create expectations for new members
for _, userID := range tt.userIDs {
exists := false
for _, membership := range tt.mockMemberships {
if int(membership.UserID) == userID && int(membership.TeamID) == tt.teamID {
exists = true
break
}
}
if !exists {
mockAPI.On("CreateTeamMembership", tt.teamID, userID).Return(tt.mockCreateError)
}
}
client := &PortainerClient{cli: mockAPI}
err := client.UpdateTeamMembers(tt.teamID, tt.userIDs)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/tests/integration/environment_test.go:
--------------------------------------------------------------------------------
```go
package integration
import (
"encoding/json"
"fmt"
"testing"
mcpmodels "github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/client-api-go/v2/client/utils"
apimodels "github.com/portainer/client-api-go/v2/pkg/models"
"github.com/portainer/portainer-mcp/internal/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/portainer/portainer-mcp/tests/integration/helpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// Test data constants
testEndpointName = "test-endpoint"
testTag1Name = "tag1"
testTag2Name = "tag2"
)
// prepareTestEnvironment prepares the test environment for the tests
// It enables Edge Compute settings and creates an Edge Docker endpoint
func prepareEnvironmentManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) {
host, port := env.Portainer.GetHostAndPort()
serverAddr := fmt.Sprintf("%s:%s", host, port)
tunnelAddr := fmt.Sprintf("%s:8000", host)
err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
require.NoError(t, err, "Failed to update settings")
_, err = env.RawClient.CreateEdgeDockerEndpoint(testEndpointName)
require.NoError(t, err, "Failed to create Edge Docker endpoint")
}
// TestEnvironmentManagement is an integration test suite that verifies the complete
// lifecycle of environment management in Portainer MCP. It tests the retrieval and
// configuration of environments, including tag management, user access controls,
// and team access policies.
func TestEnvironmentManagement(t *testing.T) {
env := helpers.NewTestEnv(t)
defer env.Cleanup(t)
// Prepare the test environment
prepareEnvironmentManagementTestEnvironment(t, env)
var environment models.Environment
// Subtest: Environment Retrieval
// Verifies that:
// - The environment is correctly retrieved from the system
// - The environment has the expected default properties (type, status)
// - No tags, user accesses, or team accesses are initially assigned
// - Compares MCP handler output with direct client API call result
t.Run("Environment Retrieval", func(t *testing.T) {
handler := env.MCPServer.HandleGetEnvironments()
result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
require.NoError(t, err, "Failed to get environments via MCP handler")
assert.Len(t, result.Content, 1, "Expected exactly one environment from MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
assert.True(t, ok, "Expected text content in MCP response")
var environments []models.Environment
err = json.Unmarshal([]byte(textContent.Text), &environments)
require.NoError(t, err, "Failed to unmarshal environments from MCP response")
require.Len(t, environments, 1, "Expected exactly one environment after unmarshalling")
// Extract the environment for subsequent tests
environment = environments[0]
// Fetch the same endpoint directly via the client
rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
require.NoError(t, err, "Failed to get endpoint directly via client")
// Convert the raw endpoint to the expected Environment model using the package's converter
expectedEnvironment := models.ConvertEndpointToEnvironment(rawEndpoint)
// Compare the Environment struct from MCP handler with the one converted from the direct client call
assert.Equal(t, expectedEnvironment, environment, "Mismatch between MCP handler environment and converted client environment")
})
// Subtest: Tag Management
// Verifies that:
// - New tags can be created in the system
// - Multiple tags can be assigned to an environment simultaneously
// - The environment correctly reflects the assigned tag IDs
// - The tags are properly persisted in the endpoint configuration
t.Run("Tag Management", func(t *testing.T) {
tagId1, err := env.RawClient.CreateTag(testTag1Name)
require.NoError(t, err, "Failed to create first tag")
tagId2, err := env.RawClient.CreateTag(testTag2Name)
require.NoError(t, err, "Failed to create second tag")
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(environment.ID),
"tagIds": []any{float64(tagId1), float64(tagId2)},
})
handler := env.MCPServer.HandleUpdateEnvironmentTags()
_, err = handler(env.Ctx, request)
require.NoError(t, err, "Failed to update environment tags via MCP handler")
// Verify by fetching endpoint directly via client
rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
require.NoError(t, err, "Failed to get endpoint via client after tag update")
assert.ElementsMatch(t, []int64{tagId1, tagId2}, rawEndpoint.TagIds, "Tag IDs mismatch (Client check)") // Use ElementsMatch for unordered comparison
})
// Subtest: User Access Management
// Verifies that:
// - User access policies can be assigned to an environment
// - Multiple users with different access levels can be configured
// - Access levels are correctly mapped to appropriate role IDs
// - The environment's user access policies are properly updated and persisted
t.Run("User Access Management", func(t *testing.T) {
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(environment.ID),
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
map[string]any{"id": float64(2), "access": "standard_user"},
},
})
handler := env.MCPServer.HandleUpdateEnvironmentUserAccesses()
_, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update environment user accesses via MCP handler")
// Verify by fetching endpoint directly via client
rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
require.NoError(t, err, "Failed to get endpoint via client after user access update")
expectedRawUserAccesses := utils.BuildAccessPolicies[apimodels.PortainerUserAccessPolicies](map[int64]string{
1: "environment_administrator",
2: "standard_user",
})
assert.Equal(t, expectedRawUserAccesses, rawEndpoint.UserAccessPolicies, "User access policies mismatch (Client check)")
})
// Subtest: Team Access Management
// Verifies that:
// - Team access policies can be assigned to an environment
// - Multiple teams with different access levels can be configured
// - Access levels are correctly mapped to appropriate role IDs
// - The environment's team access policies are properly updated and persisted
t.Run("Team Access Management", func(t *testing.T) {
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(environment.ID),
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
map[string]any{"id": float64(2), "access": "standard_user"},
},
})
handler := env.MCPServer.HandleUpdateEnvironmentTeamAccesses()
_, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update environment team accesses via MCP handler")
// Verify by fetching endpoint directly via client
rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
require.NoError(t, err, "Failed to get endpoint via client after team access update")
expectedRawTeamAccesses := utils.BuildAccessPolicies[apimodels.PortainerTeamAccessPolicies](map[int64]string{
1: "environment_administrator",
2: "standard_user",
})
assert.Equal(t, expectedRawTeamAccesses, rawEndpoint.TeamAccessPolicies, "Team access policies mismatch (Client check)")
})
}
```
--------------------------------------------------------------------------------
/tests/integration/docker_test.go:
--------------------------------------------------------------------------------
```go
package integration
import (
"encoding/json"
"fmt"
"testing"
mcpmodels "github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/internal/mcp"
"github.com/portainer/portainer-mcp/tests/integration/containers"
"github.com/portainer/portainer-mcp/tests/integration/helpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// Test data constants
testLocalEndpointName = "test-local-endpoint"
testLocalEndpointID = 1
testVolumeName = "test-proxy-volume"
)
// prepareDockerProxyTestEnvironment prepares the test environment for the tests
// It creates a local Docker endpoint
func prepareDockerProxyTestEnvironment(t *testing.T, env *helpers.TestEnv) {
_, err := env.RawClient.CreateLocalDockerEndpoint(testLocalEndpointName)
require.NoError(t, err, "Failed to create Local Docker endpoint")
}
// TestDockerProxy is an integration test suite that verifies the Docker proxy functionality
// provided by the Portainer MCP server. It tests the ability to proxy various Docker API requests
// to a specified environment, including:
// - Retrieving Docker version information (GET /version)
// - Creating a volume (POST /volumes/create)
// - Listing volumes with filters (GET /volumes?filters=...)
// - Removing a volume (DELETE /volumes/{name})
// It primarily tests against volumes, as testing container operations would require
// pulling images beforehand, potentially leading to rate limiting issues in CI/CD
// or rapid testing scenarios.
func TestDockerProxy(t *testing.T) {
env := helpers.NewTestEnv(t, containers.WithDockerSocketBind(true))
defer env.Cleanup(t)
// Prepare the test environment
prepareDockerProxyTestEnvironment(t, env)
// Subtest: GET /version
// Verifies that:
// - A simple GET request to the Docker /version endpoint can be successfully proxied.
// - The handler returns a non-empty response without errors.
// - The response content contains expected fields like ApiVersion and Version.
t.Run("GET /version", func(t *testing.T) {
request := mcp.CreateMCPRequest(map[string]any{
"environmentId": float64(testLocalEndpointID),
"method": "GET",
"dockerAPIPath": "/version",
"queryParams": nil, // No query params for /version
"headers": nil, // No specific headers needed
"body": "", // No body for GET request
})
handler := env.MCPServer.HandleDockerProxy()
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Handler execution failed")
require.NotNil(t, result, "Handler returned nil result")
require.Len(t, result.Content, 1, "Expected exactly one content item in result")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in result")
require.NotEmpty(t, textContent.Text, "Result text content should not be empty")
// Unmarshal and check specific fields
var versionInfo map[string]any // Using map[string]any for flexibility
err = json.Unmarshal([]byte(textContent.Text), &versionInfo)
require.NoError(t, err, "Failed to unmarshal version JSON")
assert.Contains(t, versionInfo, "ApiVersion", "Version info should contain ApiVersion")
assert.NotEmpty(t, versionInfo["ApiVersion"], "ApiVersion should not be empty")
assert.Contains(t, versionInfo, "Version", "Version info should contain Version")
assert.NotEmpty(t, versionInfo["Version"], "Version should not be empty")
})
// Subtest: Create Volume
// Verifies that:
// - A POST request to /volumes/create proxies correctly.
// - A volume with the specified name is created.
// - The handler response reflects the created volume details.
t.Run("Create Volume", func(t *testing.T) {
createBody := fmt.Sprintf(`{"Name": "%s"}`, testVolumeName)
request := mcp.CreateMCPRequest(map[string]any{
"environmentId": float64(testLocalEndpointID),
"method": "POST",
"dockerAPIPath": "/volumes/create",
"headers": []any{
map[string]any{"key": "Content-Type", "value": "application/json"},
},
"body": createBody,
})
handler := env.MCPServer.HandleDockerProxy()
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Create Volume handler execution failed")
require.NotNil(t, result, "Create Volume handler returned nil result")
require.Len(t, result.Content, 1, "Expected one content item for Create Volume")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content for Create Volume")
require.NotEmpty(t, textContent.Text, "Create Volume response text should not be empty")
var volumeInfo map[string]any
err = json.Unmarshal([]byte(textContent.Text), &volumeInfo)
require.NoError(t, err, "Failed to unmarshal Create Volume response")
assert.Equal(t, testVolumeName, volumeInfo["Name"], "Volume name in response mismatch")
})
// Subtest: List Volumes with Filter
// Verifies that:
// - A GET request to /volumes with a name filter proxies correctly.
// - The response contains only the volume created earlier.
t.Run("List Volumes with Filter", func(t *testing.T) {
filterJSON := fmt.Sprintf(`{"name":["%s"]}`, testVolumeName)
request := mcp.CreateMCPRequest(map[string]any{
"environmentId": float64(testLocalEndpointID),
"method": "GET",
"dockerAPIPath": "/volumes",
"queryParams": []any{
map[string]any{"key": "filters", "value": filterJSON},
},
})
handler := env.MCPServer.HandleDockerProxy()
result, err := handler(env.Ctx, request)
require.NoError(t, err, "List Volumes handler execution failed")
require.NotNil(t, result, "List Volumes handler returned nil result")
require.Len(t, result.Content, 1, "Expected one content item for List Volumes")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content for List Volumes")
require.NotEmpty(t, textContent.Text, "List Volumes response text should not be empty")
var listResponse map[string][]map[string]any
err = json.Unmarshal([]byte(textContent.Text), &listResponse)
require.NoError(t, err, "Failed to unmarshal List Volumes response")
require.Contains(t, listResponse, "Volumes", "List response missing 'Volumes' key")
require.Len(t, listResponse["Volumes"], 1, "Expected exactly one volume in the filtered list")
assert.Equal(t, testVolumeName, listResponse["Volumes"][0]["Name"], "Filtered volume name mismatch")
})
// Subtest: Remove Volume
// Verifies that:
// - A DELETE request to /volumes/{name} proxies correctly.
// - The volume created earlier is successfully removed.
// - The handler response is empty (reflecting Docker's 204 No Content).
t.Run("Remove Volume", func(t *testing.T) {
request := mcp.CreateMCPRequest(map[string]any{
"environmentId": float64(testLocalEndpointID),
"method": "DELETE",
"dockerAPIPath": "/volumes/" + testVolumeName,
})
handler := env.MCPServer.HandleDockerProxy()
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Remove Volume handler execution failed")
require.NotNil(t, result, "Remove Volume handler returned nil result")
require.Len(t, result.Content, 1, "Expected one content item for Remove Volume")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content for Remove Volume")
assert.Empty(t, textContent.Text, "Remove Volume response text should be empty for 204 No Content")
})
}
```
--------------------------------------------------------------------------------
/internal/mcp/docker_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func createMockHttpResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
}
}
// errorReader simulates an error during io.ReadAll
type errorReader struct{}
func (r *errorReader) Read(p []byte) (n int, err error) {
return 0, fmt.Errorf("simulated read error")
}
func (r *errorReader) Close() error {
return nil
}
func TestHandleDockerProxy_ParameterValidation(t *testing.T) {
tests := []struct {
name string
inputParams map[string]any
expectedErrorMsg string
}{
{
name: "invalid body type (not a string)",
inputParams: map[string]any{
"environmentId": float64(2),
"dockerAPIPath": "/containers/create",
"method": "POST",
"body": 123.45, // Invalid type for body
},
expectedErrorMsg: "body must be a string",
},
{
name: "missing environmentId",
inputParams: map[string]any{
"dockerAPIPath": "/containers/json",
"method": "GET",
},
expectedErrorMsg: "environmentId is required",
},
{
name: "missing dockerAPIPath",
inputParams: map[string]any{
"environmentId": float64(1),
"method": "GET",
},
expectedErrorMsg: "dockerAPIPath is required",
},
{
name: "missing method",
inputParams: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "/containers/json",
},
expectedErrorMsg: "method is required",
},
{
name: "invalid dockerAPIPath (no leading slash)",
inputParams: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "containers/json",
"method": "GET",
},
expectedErrorMsg: "dockerAPIPath must start with a leading slash",
},
{
name: "invalid HTTP method",
inputParams: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "/containers/json",
"method": "INVALID",
},
expectedErrorMsg: "invalid method: INVALID",
},
{
name: "invalid queryParams type (not an array)",
inputParams: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "/containers/json",
"method": "GET",
"queryParams": "not-an-array", // Invalid type
},
expectedErrorMsg: "queryParams must be an array",
},
{
name: "invalid queryParams content (missing key)",
inputParams: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "/containers/json",
"method": "GET",
"queryParams": []any{map[string]any{"value": "true"}}, // Missing 'key'
},
expectedErrorMsg: "invalid query params: invalid key: <nil>",
},
{
name: "invalid headers type (not an array)",
inputParams: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "/containers/json",
"method": "GET",
"headers": map[string]any{"key": "value"}, // Invalid type
},
expectedErrorMsg: "headers must be an array",
},
{
name: "invalid headers content (value not string)",
inputParams: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "/containers/json",
"method": "GET",
"headers": []any{map[string]any{"key": "X-Custom", "value": 123}}, // Value not string
},
expectedErrorMsg: "invalid headers: invalid value: 123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &PortainerMCPServer{}
request := CreateMCPRequest(tt.inputParams)
handler := server.HandleDockerProxy()
result, err := handler(context.Background(), request)
// All parameter/validation errors now return (result{IsError: true}, nil)
assert.NoError(t, err) // Handler now returns nil error
assert.NotNil(t, result) // Handler returns a result object
assert.True(t, result.IsError, "result.IsError should be true for parameter validation errors")
assert.Len(t, result.Content, 1) // Expect one content item for the error message
textContent, ok := result.Content[0].(mcp.TextContent) // Content should be TextContent
assert.True(t, ok, "Result content should be mcp.TextContent for errors")
assert.Contains(t, textContent.Text, tt.expectedErrorMsg, "Error message mismatch")
})
}
}
func TestHandleDockerProxy_ClientInteraction(t *testing.T) {
type testCase struct {
name string
input map[string]any // Parameters for the MCP request
mock struct { // Details for mocking the client call
response *http.Response
err error
}
expect struct { // Expected outcome
errSubstring string // Check for error containing this text (if error expected)
resultText string // Expected text result (if success expected)
}
}
tests := []testCase{
{
name: "successful GET request", // Query params are parsed by toolgen, but not yet passed by handler
input: map[string]any{
"environmentId": float64(1),
"dockerAPIPath": "/containers/json",
"method": "GET",
"queryParams": []any{ //
map[string]any{"key": "all", "value": "true"},
map[string]any{"key": "filter", "value": "dangling"},
},
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusOK, `[{"Id":"123"}]`),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
resultText: `[{"Id":"123"}]`,
},
},
{
name: "successful POST request with body",
input: map[string]any{
"environmentId": float64(2),
"dockerAPIPath": "/containers/create",
"method": "POST",
"body": `{"name":"test"}`,
"headers": []any{
map[string]any{"key": "X-Custom", "value": "test-value"},
map[string]any{"key": "Authorization", "value": "Bearer abc"},
},
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusCreated, `{"Id":"456"}`),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
resultText: `{"Id":"456"}`,
},
},
{
name: "client API error",
input: map[string]any{
"environmentId": float64(3),
"dockerAPIPath": "/version",
"method": "GET",
},
mock: struct {
response *http.Response
err error
}{
response: nil,
err: errors.New("portainer api error"),
},
expect: struct {
errSubstring string
resultText string
}{
errSubstring: "failed to send Docker API request: portainer api error",
},
},
{
name: "error reading response body",
input: map[string]any{
"environmentId": float64(4),
"dockerAPIPath": "/info",
"method": "GET",
},
mock: struct {
response *http.Response
err error
}{
response: &http.Response{
StatusCode: http.StatusOK,
Body: &errorReader{}, // Simulate read error
},
err: nil, // No client error, but response body read fails
},
expect: struct {
errSubstring string
resultText string
}{
errSubstring: "failed to read Docker API response: simulated read error",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mockClient := new(MockPortainerClient)
mockClient.On("ProxyDockerRequest", mock.AnythingOfType("models.DockerProxyRequestOptions")).
Return(tc.mock.response, tc.mock.err)
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(tc.input)
handler := server.HandleDockerProxy()
result, err := handler(context.Background(), request)
if tc.expect.errSubstring != "" {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "result.IsError should be true for errors")
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok, "Result content should be mcp.TextContent for errors")
assert.Contains(t, textContent.Text, tc.expect.errSubstring)
} else {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok)
assert.Equal(t, tc.expect.resultText, textContent.Text)
}
mockClient.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/tests/integration/group_test.go:
--------------------------------------------------------------------------------
```go
package integration
import (
"encoding/json"
"fmt"
"testing"
mcpmodels "github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/internal/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/portainer/portainer-mcp/tests/integration/helpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testGroupName = "test-mcp-group"
testGroupUpdatedName = "test-mcp-group-updated"
testGroupTagName1 = "test-group-tag1"
testGroupTagName2 = "test-group-tag2"
testEnvName = "test-group-env"
)
// prepareEnvironmentGroupTestEnvironment prepares the test environment for environment group tests
func prepareEnvironmentGroupTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int) {
// Enable Edge features in Portainer
host, port := env.Portainer.GetHostAndPort()
serverAddr := fmt.Sprintf("%s:%s", host, port)
tunnelAddr := fmt.Sprintf("%s:8000", host)
err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
require.NoError(t, err, "Failed to update settings to enable Edge features")
// Create a test environment for association with groups
envID, err := env.RawClient.CreateEdgeDockerEndpoint(testEnvName)
require.NoError(t, err, "Failed to create test environment")
// Create test tag
tagID, err := env.RawClient.CreateTag(testGroupTagName1)
require.NoError(t, err, "Failed to create test tag")
return int(envID), int(tagID)
}
// TestEnvironmentGroupManagement is an integration test suite that verifies the complete
// lifecycle of environment group management in Portainer MCP. It tests group creation,
// listing, name updates, environment association and tag association.
func TestEnvironmentGroupManagement(t *testing.T) {
env := helpers.NewTestEnv(t)
defer env.Cleanup(t)
// Prepare the test environment
testEnvID, testTagID := prepareEnvironmentGroupTestEnvironment(t, env)
var testGroupID int
// Subtest: Environment Group Creation
// Verifies that:
// - A new environment group can be created via the MCP handler
// - The handler response indicates success with an ID
// - The created group exists in Portainer when checked directly via Raw Client
t.Run("Environment Group Creation", func(t *testing.T) {
handler := env.MCPServer.HandleCreateEnvironmentGroup()
request := mcp.CreateMCPRequest(map[string]any{
"name": testGroupName,
"environmentIds": []any{float64(testEnvID)},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to create environment group via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
// Check for success message
assert.Contains(t, textContent.Text, "Environment group created successfully with ID:", "Success message prefix mismatch")
// Verify by fetching group directly via client and finding the created group by name
group, err := env.RawClient.GetEdgeGroupByName(testGroupName)
require.NoError(t, err, "Failed to get environment group directly via client")
assert.Equal(t, testGroupName, group.Name, "Group name mismatch")
// Extract group ID for subsequent tests
testGroupID = int(group.ID)
})
// Subtest: Environment Group Listing
// Verifies that:
// - The group list can be retrieved via the MCP handler
// - The list contains the expected group
// - The group data matches the expected properties
t.Run("Environment Group Listing", func(t *testing.T) {
handler := env.MCPServer.HandleGetEnvironmentGroups()
result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
require.NoError(t, err, "Failed to get environment groups via MCP handler")
assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
assert.True(t, ok, "Expected text content in MCP response")
var retrievedGroups []models.Group
err = json.Unmarshal([]byte(textContent.Text), &retrievedGroups)
require.NoError(t, err, "Failed to unmarshal retrieved groups")
require.Len(t, retrievedGroups, 1, "Expected exactly one group after unmarshalling")
group := retrievedGroups[0]
assert.Equal(t, testGroupName, group.Name, "Group name mismatch")
// Fetch the same group directly via the client
rawGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
require.NoError(t, err, "Failed to get environment group directly via client")
// Convert the raw group to the expected Group model
expectedGroup := models.ConvertEdgeGroupToGroup(rawGroup)
assert.Equal(t, expectedGroup, group, "Group mismatch between MCP handler and direct client call")
})
// Subtest: Environment Group Name Update
// Verifies that:
// - The group name can be updated via the MCP handler
// - The handler response indicates success
// - The name is correctly updated when checked directly via Raw Client
t.Run("Environment Group Name Update", func(t *testing.T) {
handler := env.MCPServer.HandleUpdateEnvironmentGroupName()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testGroupID),
"name": testGroupUpdatedName,
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update environment group name via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
assert.Contains(t, textContent.Text, "Environment group name updated successfully", "Success message mismatch")
// Verify by fetching group directly via raw client
updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
require.NoError(t, err, "Failed to get environment group directly via client")
assert.Equal(t, testGroupUpdatedName, updatedGroup.Name, "Group name was not updated")
})
// Subtest: Environment Group Tag Update
// Verifies that:
// - Tags can be associated with a group via the MCP handler
// - The handler response indicates success
// - The tags are correctly associated when checked directly via Raw Client
t.Run("Environment Group Tag Update", func(t *testing.T) {
// Create a second tag
tagID2, err := env.RawClient.CreateTag(testGroupTagName2)
require.NoError(t, err, "Failed to create second test tag")
handler := env.MCPServer.HandleUpdateEnvironmentGroupTags()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testGroupID),
"tagIds": []any{float64(testTagID), float64(tagID2)},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update environment group tags via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
assert.Contains(t, textContent.Text, "Environment group tags updated successfully", "Success message mismatch")
// Verify by fetching group directly via raw client
updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
require.NoError(t, err, "Failed to get environment group directly via client")
assert.ElementsMatch(t, []int64{int64(testTagID), int64(tagID2)}, updatedGroup.TagIds, "Tag IDs mismatch")
})
// Subtest: Environment Group Environments Update
// Verifies that:
// - Environment associations can be updated via the MCP handler
// - The handler response indicates success
// - The environment associations are correctly updated when checked directly via Raw Client
t.Run("Environment Group Environments Update", func(t *testing.T) {
// Create a second environment
env2Name := "test-env-2"
env2ID, err := env.RawClient.CreateEdgeDockerEndpoint(env2Name)
require.NoError(t, err, "Failed to create second test environment")
handler := env.MCPServer.HandleUpdateEnvironmentGroupEnvironments()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testGroupID),
"environmentIds": []any{float64(testEnvID), float64(env2ID)},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update environment group environments via MCP handler")
textContent, ok := result.Content[0].(mcpmodels.TextContent)
require.True(t, ok, "Expected text content in MCP response")
assert.Contains(t, textContent.Text, "Environment group environments updated successfully", "Success message mismatch")
// Verify by fetching group directly via raw client
updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
require.NoError(t, err, "Failed to get environment group directly via client")
assert.ElementsMatch(t, []int64{int64(testEnvID), int64(env2ID)}, updatedGroup.Endpoints, "Environment IDs mismatch")
})
}
```
--------------------------------------------------------------------------------
/pkg/portainer/client/mocks_test.go:
--------------------------------------------------------------------------------
```go
package client
import (
"net/http"
"github.com/portainer/client-api-go/v2/client"
apimodels "github.com/portainer/client-api-go/v2/pkg/models"
"github.com/stretchr/testify/mock"
)
// Mock Implementation Patterns:
//
// This file contains mock implementations of the PortainerAPIClient interface.
// The following patterns are used throughout the mocks:
//
// 1. Methods returning (T, error):
// - Uses m.Called() to record the method call and get mock behavior
// - Includes nil check on first return value to avoid type assertion panics
// - Example:
// func (m *Mock) Method() (T, error) {
// args := m.Called()
// if args.Get(0) == nil {
// return nil, args.Error(1)
// }
// return args.Get(0).(T), args.Error(1)
// }
//
// 2. Methods returning only error:
// - Uses m.Called() with any parameters
// - Returns only the error value
// - Example:
// func (m *Mock) Method(param string) error {
// args := m.Called(param)
// return args.Error(0)
// }
//
// 3. Methods with primitive return types:
// - Uses type-specific getters (e.g., Int64, String)
// - Example:
// func (m *Mock) Method() (int64, error) {
// args := m.Called()
// return args.Get(0).(int64), args.Error(1)
// }
//
// Usage in Tests:
// mock := new(MockPortainerAPI)
// mock.On("MethodName").Return(expectedValue, nil)
// result, err := mock.MethodName()
// mock.AssertExpectations(t)
// MockPortainerAPI is a mock of the PortainerAPIClient interface
type MockPortainerAPI struct {
mock.Mock
}
// ListEdgeGroups mocks the ListEdgeGroups method
func (m *MockPortainerAPI) ListEdgeGroups() ([]*apimodels.EdgegroupsDecoratedEdgeGroup, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.EdgegroupsDecoratedEdgeGroup), args.Error(1)
}
// CreateEdgeGroup mocks the CreateEdgeGroup method
func (m *MockPortainerAPI) CreateEdgeGroup(name string, environmentIds []int64) (int64, error) {
args := m.Called(name, environmentIds)
return args.Get(0).(int64), args.Error(1)
}
// UpdateEdgeGroup mocks the UpdateEdgeGroup method
func (m *MockPortainerAPI) UpdateEdgeGroup(id int64, name *string, environmentIds *[]int64, tagIds *[]int64) error {
args := m.Called(id, name, environmentIds, tagIds)
return args.Error(0)
}
// ListEdgeStacks mocks the ListEdgeStacks method
func (m *MockPortainerAPI) ListEdgeStacks() ([]*apimodels.PortainereeEdgeStack, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.PortainereeEdgeStack), args.Error(1)
}
// CreateEdgeStack mocks the CreateEdgeStack method
func (m *MockPortainerAPI) CreateEdgeStack(name string, file string, environmentGroupIds []int64) (int64, error) {
args := m.Called(name, file, environmentGroupIds)
return args.Get(0).(int64), args.Error(1)
}
// UpdateEdgeStack mocks the UpdateEdgeStack method
func (m *MockPortainerAPI) UpdateEdgeStack(id int64, file string, environmentGroupIds []int64) error {
args := m.Called(id, file, environmentGroupIds)
return args.Error(0)
}
// GetEdgeStackFile mocks the GetEdgeStackFile method
func (m *MockPortainerAPI) GetEdgeStackFile(id int64) (string, error) {
args := m.Called(id)
return args.String(0), args.Error(1)
}
// ListEndpointGroups mocks the ListEndpointGroups method
func (m *MockPortainerAPI) ListEndpointGroups() ([]*apimodels.PortainerEndpointGroup, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.PortainerEndpointGroup), args.Error(1)
}
// CreateEndpointGroup mocks the CreateEndpointGroup method
func (m *MockPortainerAPI) CreateEndpointGroup(name string, associatedEndpoints []int64) (int64, error) {
args := m.Called(name, associatedEndpoints)
return args.Get(0).(int64), args.Error(1)
}
// UpdateEndpointGroup mocks the UpdateEndpointGroup method
func (m *MockPortainerAPI) UpdateEndpointGroup(id int64, name *string, userAccesses *map[int64]string, teamAccesses *map[int64]string) error {
args := m.Called(id, name, userAccesses, teamAccesses)
return args.Error(0)
}
// AddEnvironmentToEndpointGroup mocks the AddEnvironmentToEndpointGroup method
func (m *MockPortainerAPI) AddEnvironmentToEndpointGroup(groupId int64, environmentId int64) error {
args := m.Called(groupId, environmentId)
return args.Error(0)
}
// RemoveEnvironmentFromEndpointGroup mocks the RemoveEnvironmentFromEndpointGroup method
func (m *MockPortainerAPI) RemoveEnvironmentFromEndpointGroup(groupId int64, environmentId int64) error {
args := m.Called(groupId, environmentId)
return args.Error(0)
}
// ListEndpoints mocks the ListEndpoints method
func (m *MockPortainerAPI) ListEndpoints() ([]*apimodels.PortainereeEndpoint, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.PortainereeEndpoint), args.Error(1)
}
// GetEndpoint mocks the GetEndpoint method
func (m *MockPortainerAPI) GetEndpoint(id int64) (*apimodels.PortainereeEndpoint, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*apimodels.PortainereeEndpoint), args.Error(1)
}
// UpdateEndpoint mocks the UpdateEndpoint method
func (m *MockPortainerAPI) UpdateEndpoint(id int64, tagIds *[]int64, userAccesses *map[int64]string, teamAccesses *map[int64]string) error {
args := m.Called(id, tagIds, userAccesses, teamAccesses)
return args.Error(0)
}
// GetSettings mocks the GetSettings method
func (m *MockPortainerAPI) GetSettings() (*apimodels.PortainereeSettings, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*apimodels.PortainereeSettings), args.Error(1)
}
// ListTags mocks the ListTags method
func (m *MockPortainerAPI) ListTags() ([]*apimodels.PortainerTag, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.PortainerTag), args.Error(1)
}
// CreateTag mocks the CreateTag method
func (m *MockPortainerAPI) CreateTag(name string) (int64, error) {
args := m.Called(name)
return args.Get(0).(int64), args.Error(1)
}
// ListTeams mocks the ListTeams method
func (m *MockPortainerAPI) ListTeams() ([]*apimodels.PortainerTeam, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.PortainerTeam), args.Error(1)
}
// ListTeamMemberships mocks the ListTeamMemberships method
func (m *MockPortainerAPI) ListTeamMemberships() ([]*apimodels.PortainerTeamMembership, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.PortainerTeamMembership), args.Error(1)
}
// CreateTeam mocks the CreateTeam method
func (m *MockPortainerAPI) CreateTeam(name string) (int64, error) {
args := m.Called(name)
return args.Get(0).(int64), args.Error(1)
}
// UpdateTeamName mocks the UpdateTeamName method
func (m *MockPortainerAPI) UpdateTeamName(id int, name string) error {
args := m.Called(id, name)
return args.Error(0)
}
// DeleteTeamMembership mocks the DeleteTeamMembership method
func (m *MockPortainerAPI) DeleteTeamMembership(id int) error {
args := m.Called(id)
return args.Error(0)
}
// CreateTeamMembership mocks the CreateTeamMembership method
func (m *MockPortainerAPI) CreateTeamMembership(teamId int, userId int) error {
args := m.Called(teamId, userId)
return args.Error(0)
}
// ListUsers mocks the ListUsers method
func (m *MockPortainerAPI) ListUsers() ([]*apimodels.PortainereeUser, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*apimodels.PortainereeUser), args.Error(1)
}
// UpdateUserRole mocks the UpdateUserRole method
func (m *MockPortainerAPI) UpdateUserRole(id int, role int64) error {
args := m.Called(id, role)
return args.Error(0)
}
// GetVersion mocks the GetVersion method
func (m *MockPortainerAPI) GetVersion() (string, error) {
args := m.Called()
return args.String(0), args.Error(1)
}
// ProxyDockerRequest mocks the ProxyDockerRequest method
func (m *MockPortainerAPI) ProxyDockerRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error) {
args := m.Called(environmentId, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*http.Response), args.Error(1)
}
// ProxyKubernetesRequest mocks the ProxyKubernetesRequest method
func (m *MockPortainerAPI) ProxyKubernetesRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error) {
args := m.Called(environmentId, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*http.Response), args.Error(1)
}
```
--------------------------------------------------------------------------------
/internal/mcp/team_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
)
func TestHandleCreateTeam(t *testing.T) {
tests := []struct {
name string
teamName string
mockID int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful team creation",
teamName: "test-team",
mockID: 1,
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "test-team",
}
},
},
{
name: "api error",
teamName: "test-team",
mockID: 0,
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "test-team",
}
},
},
{
name: "missing name parameter",
teamName: "",
mockID: 0,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
// No need to set any parameters as the request will be invalid
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("CreateTeam", tt.teamName).Return(tt.mockID, tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleCreateTeam()
result, err := handler(context.Background(), request)
if tt.expectError {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "result.IsError should be true for expected errors")
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok, "Result content should be mcp.TextContent for errors")
if tt.mockError != nil {
assert.Contains(t, textContent.Text, tt.mockError.Error())
} else {
assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
}
} else {
assert.NoError(t, err)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok)
assert.Contains(t, textContent.Text, fmt.Sprintf("ID: %d", tt.mockID))
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleGetTeams(t *testing.T) {
tests := []struct {
name string
mockTeams []models.Team
mockError error
expectError bool
}{
{
name: "successful teams retrieval",
mockTeams: []models.Team{
{ID: 1, Name: "team1"},
{ID: 2, Name: "team2"},
},
mockError: nil,
expectError: false,
},
{
name: "api error",
mockTeams: nil,
mockError: fmt.Errorf("api error"),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
mockClient.On("GetTeams").Return(tt.mockTeams, tt.mockError)
server := &PortainerMCPServer{
cli: mockClient,
}
handler := server.HandleGetTeams()
result, err := handler(context.Background(), mcp.CallToolRequest{})
if tt.expectError {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "result.IsError should be true for expected errors")
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok, "Result content should be mcp.TextContent for errors")
if tt.mockError != nil {
assert.Contains(t, textContent.Text, tt.mockError.Error())
} else {
assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
}
} else {
assert.NoError(t, err)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok)
var teams []models.Team
err = json.Unmarshal([]byte(textContent.Text), &teams)
assert.NoError(t, err)
assert.Equal(t, tt.mockTeams, teams)
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleUpdateTeamName(t *testing.T) {
tests := []struct {
name string
inputID int
inputName string
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful name update",
inputID: 1,
inputName: "new-name",
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"name": "new-name",
}
},
},
{
name: "api error",
inputID: 1,
inputName: "new-name",
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"name": "new-name",
}
},
},
{
name: "missing id parameter",
inputID: 0,
inputName: "new-name",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "new-name",
}
},
},
{
name: "missing name parameter",
inputID: 1,
inputName: "",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateTeamName", tt.inputID, tt.inputName).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateTeamName()
result, err := handler(context.Background(), request)
if tt.expectError {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "result.IsError should be true for expected errors")
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok, "Result content should be mcp.TextContent for errors")
if tt.mockError != nil {
assert.Contains(t, textContent.Text, tt.mockError.Error())
} else {
assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
}
} else {
assert.NoError(t, err)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok)
assert.Contains(t, textContent.Text, "successfully")
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleUpdateTeamMembers(t *testing.T) {
tests := []struct {
name string
inputID int
inputUsers []int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful members update",
inputID: 1,
inputUsers: []int{1, 2, 3},
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "api error",
inputID: 1,
inputUsers: []int{1, 2, 3},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "missing id parameter",
inputID: 0,
inputUsers: []int{1, 2, 3},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"userIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "missing userIds parameter",
inputID: 1,
inputUsers: nil,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateTeamMembers", tt.inputID, tt.inputUsers).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateTeamMembers()
result, err := handler(context.Background(), request)
if tt.expectError {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "result.IsError should be true for expected errors")
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok, "Result content should be mcp.TextContent for errors")
if tt.mockError != nil {
assert.Contains(t, textContent.Text, tt.mockError.Error())
} else {
assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
}
} else {
assert.NoError(t, err)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(mcp.TextContent)
assert.True(t, ok)
assert.Contains(t, textContent.Text, "successfully")
}
mockClient.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/pkg/portainer/client/access_group_test.go:
--------------------------------------------------------------------------------
```go
package client
import (
"errors"
"testing"
apimodels "github.com/portainer/client-api-go/v2/pkg/models"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestGetAccessGroups(t *testing.T) {
tests := []struct {
name string
mockEndpointGroups []*apimodels.PortainerEndpointGroup
mockEndpoints []*apimodels.PortainereeEndpoint
mockEndpointGroupsErr error
mockEndpointsErr error
expected []models.AccessGroup
expectedError bool
}{
{
name: "successful retrieval",
mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
{
ID: 1,
Name: "group1",
UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
"1": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
"2": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
"3": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
"4": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
"5": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
},
TeamAccessPolicies: apimodels.PortainerTeamAccessPolicies{
"6": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
"7": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
"8": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
"9": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
"10": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
},
},
},
mockEndpoints: []*apimodels.PortainereeEndpoint{
{ID: 1, Name: "endpoint1", GroupID: 1},
{ID: 2, Name: "endpoint2", GroupID: 1},
{ID: 3, Name: "endpoint3", GroupID: 2},
},
expected: []models.AccessGroup{
{
ID: 1,
Name: "group1",
EnvironmentIds: []int{1, 2},
UserAccesses: map[int]string{
1: "environment_administrator",
2: "helpdesk_user",
3: "standard_user",
4: "readonly_user",
5: "operator_user",
},
TeamAccesses: map[int]string{
6: "environment_administrator",
7: "helpdesk_user",
8: "standard_user",
9: "readonly_user",
10: "operator_user",
},
},
},
},
{
name: "endpoint group list error",
mockEndpointGroupsErr: errors.New("failed to list groups"),
expectedError: true,
},
{
name: "endpoint list error",
mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
{ID: 1, Name: "group1"},
},
mockEndpointsErr: errors.New("failed to list endpoints"),
expectedError: true,
},
{
name: "empty groups with endpoints",
mockEndpointGroups: []*apimodels.PortainerEndpointGroup{},
mockEndpoints: []*apimodels.PortainereeEndpoint{
{ID: 1, Name: "endpoint1", GroupID: 1},
{ID: 2, Name: "endpoint2", GroupID: 2},
},
expected: []models.AccessGroup{},
},
{
name: "groups with empty endpoints",
mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
{
ID: 1,
Name: "group1",
UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
"1": apimodels.PortainerAccessPolicy{RoleID: 1},
},
},
},
mockEndpoints: []*apimodels.PortainereeEndpoint{},
expected: []models.AccessGroup{
{
ID: 1,
Name: "group1",
EnvironmentIds: []int{},
UserAccesses: map[int]string{
1: "environment_administrator",
},
TeamAccesses: map[int]string{},
},
},
},
{
name: "both empty",
mockEndpointGroups: []*apimodels.PortainerEndpointGroup{},
mockEndpoints: []*apimodels.PortainereeEndpoint{},
expected: []models.AccessGroup{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("ListEndpointGroups").Return(tt.mockEndpointGroups, tt.mockEndpointGroupsErr)
mockAPI.On("ListEndpoints").Return(tt.mockEndpoints, tt.mockEndpointsErr)
client := &PortainerClient{cli: mockAPI}
groups, err := client.GetAccessGroups()
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, groups)
mockAPI.AssertExpectations(t)
})
}
}
func TestCreateAccessGroup(t *testing.T) {
tests := []struct {
name string
groupName string
envIDs []int
mockReturnID int64
mockError error
expected int
expectedError bool
}{
{
name: "successful creation",
groupName: "newgroup",
envIDs: []int{1, 2, 3},
mockReturnID: 1,
expected: 1,
},
{
name: "creation error",
groupName: "newgroup",
envIDs: []int{1},
mockError: errors.New("failed to create group"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("CreateEndpointGroup", tt.groupName, mock.Anything).Return(tt.mockReturnID, tt.mockError)
client := &PortainerClient{cli: mockAPI}
id, err := client.CreateAccessGroup(tt.groupName, tt.envIDs)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, id)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateAccessGroupName(t *testing.T) {
tests := []struct {
name string
groupID int
newName string
mockError error
expectedError bool
}{
{
name: "successful update",
groupID: 1,
newName: "updated-group",
},
{
name: "update error",
groupID: 1,
newName: "updated-group",
mockError: errors.New("failed to update group"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), &tt.newName, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateAccessGroupName(tt.groupID, tt.newName)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateAccessGroupUserAccesses(t *testing.T) {
tests := []struct {
name string
groupID int
userAccesses map[int]string
mockError error
expectedError bool
}{
{
name: "successful update",
groupID: 1,
userAccesses: map[int]string{
1: "environment_administrator",
2: "readonly_user",
},
},
{
name: "update error",
groupID: 1,
userAccesses: map[int]string{
1: "environment_administrator",
},
mockError: errors.New("failed to update user accesses"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateAccessGroupUserAccesses(tt.groupID, tt.userAccesses)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestUpdateAccessGroupTeamAccesses(t *testing.T) {
tests := []struct {
name string
groupID int
teamAccesses map[int]string
mockError error
expectedError bool
}{
{
name: "successful update",
groupID: 1,
teamAccesses: map[int]string{
1: "environment_administrator",
2: "readonly_user",
},
},
{
name: "update error",
groupID: 1,
teamAccesses: map[int]string{
1: "environment_administrator",
},
mockError: errors.New("failed to update team accesses"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.UpdateAccessGroupTeamAccesses(tt.groupID, tt.teamAccesses)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestAddEnvironmentToAccessGroup(t *testing.T) {
tests := []struct {
name string
groupID int
envID int
mockError error
expectedError bool
}{
{
name: "successful addition",
groupID: 1,
envID: 2,
},
{
name: "addition error",
groupID: 1,
envID: 2,
mockError: errors.New("failed to add environment"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("AddEnvironmentToEndpointGroup", int64(tt.groupID), int64(tt.envID)).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.AddEnvironmentToAccessGroup(tt.groupID, tt.envID)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
func TestRemoveEnvironmentFromAccessGroup(t *testing.T) {
tests := []struct {
name string
groupID int
envID int
mockError error
expectedError bool
}{
{
name: "successful removal",
groupID: 1,
envID: 2,
},
{
name: "removal error",
groupID: 1,
envID: 2,
mockError: errors.New("failed to remove environment"),
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockAPI := new(MockPortainerAPI)
mockAPI.On("RemoveEnvironmentFromEndpointGroup", int64(tt.groupID), int64(tt.envID)).Return(tt.mockError)
client := &PortainerClient{cli: mockAPI}
err := client.RemoveEnvironmentFromAccessGroup(tt.groupID, tt.envID)
if tt.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
mockAPI.AssertExpectations(t)
})
}
}
```