This is page 3 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
--------------------------------------------------------------------------------
/pkg/toolgen/param_test.go:
--------------------------------------------------------------------------------
```go
package toolgen
import (
"reflect"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
// Helper function to create a ParameterParser with given arguments
func newTestParser(args map[string]any) *ParameterParser {
return NewParameterParser(mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: args,
},
})
}
func TestGetString(t *testing.T) {
tests := []struct {
name string
args map[string]any
param string
required bool
want string
wantErr bool
}{
{
name: "valid string",
args: map[string]any{"name": "test"},
param: "name",
required: true,
want: "test",
wantErr: false,
},
{
name: "missing required param",
args: map[string]any{},
param: "name",
required: true,
want: "",
wantErr: true,
},
{
name: "missing optional param",
args: map[string]any{},
param: "name",
required: false,
want: "",
wantErr: false,
},
{
name: "wrong type",
args: map[string]any{"name": 123},
param: "name",
required: true,
want: "",
wantErr: true,
},
{
name: "nil value",
args: map[string]any{"name": nil},
param: "name",
required: true,
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := newTestParser(tt.args)
got, err := p.GetString(tt.param, tt.required)
if (err != nil) != tt.wantErr {
t.Errorf("GetString() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetString() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetNumber(t *testing.T) {
tests := []struct {
name string
args map[string]any
param string
required bool
want float64
wantErr bool
}{
{
name: "valid number",
args: map[string]any{"num": float64(42)},
param: "num",
required: true,
want: 42,
wantErr: false,
},
{
name: "missing required param",
args: map[string]any{},
param: "num",
required: true,
want: 0,
wantErr: true,
},
{
name: "missing optional param",
args: map[string]any{},
param: "num",
required: false,
want: 0,
wantErr: false,
},
{
name: "wrong type",
args: map[string]any{"num": "123"},
param: "num",
required: true,
want: 0,
wantErr: true,
},
{
name: "nil value",
args: map[string]any{"num": nil},
param: "num",
required: true,
want: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := newTestParser(tt.args)
got, err := p.GetNumber(tt.param, tt.required)
if (err != nil) != tt.wantErr {
t.Errorf("GetNumber() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetNumber() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetBoolean(t *testing.T) {
tests := []struct {
name string
args map[string]any
param string
required bool
want bool
wantErr bool
}{
{
name: "valid true",
args: map[string]any{"flag": true},
param: "flag",
required: true,
want: true,
wantErr: false,
},
{
name: "valid false",
args: map[string]any{"flag": false},
param: "flag",
required: true,
want: false,
wantErr: false,
},
{
name: "missing required param",
args: map[string]any{},
param: "flag",
required: true,
want: false,
wantErr: true,
},
{
name: "missing optional param",
args: map[string]any{},
param: "flag",
required: false,
want: false,
wantErr: false,
},
{
name: "wrong type",
args: map[string]any{"flag": "true"},
param: "flag",
required: true,
want: false,
wantErr: true,
},
{
name: "nil value",
args: map[string]any{"flag": nil},
param: "flag",
required: true,
want: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := newTestParser(tt.args)
got, err := p.GetBoolean(tt.param, tt.required)
if (err != nil) != tt.wantErr {
t.Errorf("GetBoolean() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetBoolean() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetArrayOfObjects(t *testing.T) {
tests := []struct {
name string
args map[string]any
param string
required bool
want []any
wantErr bool
}{
{
name: "valid array of objects",
args: map[string]any{"objects": []any{
map[string]any{"id": 1},
map[string]any{"id": 2},
}},
param: "objects",
required: true,
want: []any{
map[string]any{"id": 1},
map[string]any{"id": 2},
},
wantErr: false,
},
{
name: "missing required param",
args: map[string]any{},
param: "objects",
required: true,
want: nil,
wantErr: true,
},
{
name: "missing optional param",
args: map[string]any{},
param: "objects",
required: false,
want: []any{},
wantErr: false,
},
{
name: "wrong type",
args: map[string]any{"objects": "not an array"},
param: "objects",
required: true,
want: nil,
wantErr: true,
},
{
name: "nil value",
args: map[string]any{"objects": nil},
param: "objects",
required: true,
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := newTestParser(tt.args)
got, err := p.GetArrayOfObjects(tt.param, tt.required)
if (err != nil) != tt.wantErr {
t.Errorf("GetArrayOfObjects() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetArrayOfObjects() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseArrayOfIntegers(t *testing.T) {
tests := []struct {
name string
input []any
want []int
wantErr bool
}{
{
name: "empty array",
input: []any{},
want: []int{},
wantErr: false,
},
{
name: "single value",
input: []any{float64(42)},
want: []int{42},
wantErr: false,
},
{
name: "multiple values",
input: []any{float64(1), float64(2), float64(3), float64(4), float64(5)},
want: []int{1, 2, 3, 4, 5},
wantErr: false,
},
{
name: "negative values",
input: []any{float64(-1), float64(-2), float64(-3)},
want: []int{-1, -2, -3},
wantErr: false,
},
{
name: "mixed positive and negative values",
input: []any{float64(0), float64(1), float64(-2), float64(3), float64(-4)},
want: []int{0, 1, -2, 3, -4},
wantErr: false,
},
{
name: "invalid string value",
input: []any{float64(1), "abc", float64(3)},
want: nil,
wantErr: true,
},
{
name: "invalid boolean value",
input: []any{float64(1), true, float64(3)},
want: nil,
wantErr: true,
},
{
name: "invalid nil value",
input: []any{float64(1), nil, float64(3)},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseArrayOfIntegers(tt.input)
// Check error status
if (err != nil) != tt.wantErr {
t.Errorf("ParseNumericArray() error = %v, wantErr %v", err, tt.wantErr)
return
}
// If we expect an error, no need to check the result
if tt.wantErr {
return
}
// Check result values
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseNumericArray() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetInt(t *testing.T) {
tests := []struct {
name string
args map[string]any
param string
required bool
want int
wantErr bool
}{
{
name: "valid integer",
args: map[string]any{"num": float64(42)},
param: "num",
required: true,
want: 42,
wantErr: false,
},
{
name: "valid zero",
args: map[string]any{"num": float64(0)},
param: "num",
required: true,
want: 0,
wantErr: false,
},
{
name: "valid negative",
args: map[string]any{"num": float64(-42)},
param: "num",
required: true,
want: -42,
wantErr: false,
},
{
name: "missing required param",
args: map[string]any{},
param: "num",
required: true,
want: 0,
wantErr: true,
},
{
name: "missing optional param",
args: map[string]any{},
param: "num",
required: false,
want: 0,
wantErr: false,
},
{
name: "wrong type string",
args: map[string]any{"num": "123"},
param: "num",
required: true,
want: 0,
wantErr: true,
},
{
name: "wrong type boolean",
args: map[string]any{"num": true},
param: "num",
required: true,
want: 0,
wantErr: true,
},
{
name: "nil value",
args: map[string]any{"num": nil},
param: "num",
required: true,
want: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := newTestParser(tt.args)
got, err := p.GetInt(tt.param, tt.required)
if (err != nil) != tt.wantErr {
t.Errorf("GetInt() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetInt() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetArrayOfIntegers(t *testing.T) {
tests := []struct {
name string
args map[string]any
param string
required bool
want []int
wantErr bool
}{
{
name: "valid array of integers",
args: map[string]any{"nums": []any{
float64(1), float64(2), float64(3),
}},
param: "nums",
required: true,
want: []int{1, 2, 3},
wantErr: false,
},
{
name: "valid array with negative numbers",
args: map[string]any{"nums": []any{
float64(-1), float64(0), float64(1),
}},
param: "nums",
required: true,
want: []int{-1, 0, 1},
wantErr: false,
},
{
name: "empty array",
args: map[string]any{"nums": []any{}},
param: "nums",
required: true,
want: []int{},
wantErr: false,
},
{
name: "missing required param",
args: map[string]any{},
param: "nums",
required: true,
want: nil,
wantErr: true,
},
{
name: "missing optional param",
args: map[string]any{},
param: "nums",
required: false,
want: []int{},
wantErr: false,
},
{
name: "invalid array with string",
args: map[string]any{"nums": []any{
float64(1), "2", float64(3),
}},
param: "nums",
required: true,
want: nil,
wantErr: true,
},
{
name: "invalid array with boolean",
args: map[string]any{"nums": []any{
float64(1), true, float64(3),
}},
param: "nums",
required: true,
want: nil,
wantErr: true,
},
{
name: "invalid array with nil",
args: map[string]any{"nums": []any{
float64(1), nil, float64(3),
}},
param: "nums",
required: true,
want: nil,
wantErr: true,
},
{
name: "wrong type (string instead of array)",
args: map[string]any{"nums": "not an array"},
param: "nums",
required: true,
want: nil,
wantErr: true,
},
{
name: "nil value",
args: map[string]any{"nums": nil},
param: "nums",
required: true,
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := newTestParser(tt.args)
got, err := p.GetArrayOfIntegers(tt.param, tt.required)
if (err != nil) != tt.wantErr {
t.Errorf("GetArrayOfIntegers() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetArrayOfIntegers() = %v, want %v", got, tt.want)
}
})
}
}
```
--------------------------------------------------------------------------------
/internal/mcp/environment_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
)
func TestHandleGetEnvironments(t *testing.T) {
tests := []struct {
name string
mockEnvironments []models.Environment
mockError error
expectError bool
}{
{
name: "successful environments retrieval",
mockEnvironments: []models.Environment{
{ID: 1, Name: "env1"},
{ID: 2, Name: "env2"},
},
mockError: nil,
expectError: false,
},
{
name: "api error",
mockEnvironments: nil,
mockError: fmt.Errorf("api error"),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
mockClient.On("GetEnvironments").Return(tt.mockEnvironments, tt.mockError)
server := &PortainerMCPServer{
cli: mockClient,
}
handler := server.HandleGetEnvironments()
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 environments []models.Environment
err = json.Unmarshal([]byte(textContent.Text), &environments)
assert.NoError(t, err)
assert.Equal(t, tt.mockEnvironments, environments)
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleUpdateEnvironmentTags(t *testing.T) {
tests := []struct {
name string
inputID int
inputTagIDs []int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful tags update",
inputID: 1,
inputTagIDs: []int{1, 2, 3},
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"tagIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "api error",
inputID: 1,
inputTagIDs: []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),
"tagIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "missing id parameter",
inputID: 0,
inputTagIDs: []int{1, 2, 3},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"tagIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "missing tagIds parameter",
inputID: 1,
inputTagIDs: 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("UpdateEnvironmentTags", tt.inputID, tt.inputTagIDs).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateEnvironmentTags()
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 TestHandleUpdateEnvironmentUserAccesses(t *testing.T) {
tests := []struct {
name string
inputID int
inputAccesses map[int]string
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful user accesses update",
inputID: 1,
inputAccesses: map[int]string{
1: "environment_administrator",
2: "standard_user",
},
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
map[string]any{"id": float64(2), "access": "standard_user"},
},
}
},
},
{
name: "api error",
inputID: 1,
inputAccesses: map[int]string{
1: "environment_administrator",
},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing id parameter",
inputID: 0,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing userAccesses parameter",
inputID: 1,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
{
name: "invalid access level",
inputID: 1,
inputAccesses: map[int]string{
1: "invalid_access",
},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "invalid_access"},
},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateEnvironmentUserAccesses", tt.inputID, tt.inputAccesses).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateEnvironmentUserAccesses()
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/validation errors")
if strings.Contains(tt.name, "invalid access level") {
assert.Contains(t, textContent.Text, "invalid user accesses")
}
}
} 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 TestHandleUpdateEnvironmentTeamAccesses(t *testing.T) {
tests := []struct {
name string
inputID int
inputAccesses map[int]string
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful team accesses update",
inputID: 1,
inputAccesses: map[int]string{
1: "environment_administrator",
2: "standard_user",
},
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
map[string]any{"id": float64(2), "access": "standard_user"},
},
}
},
},
{
name: "api error",
inputID: 1,
inputAccesses: map[int]string{
1: "environment_administrator",
},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing id parameter",
inputID: 0,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing teamAccesses parameter",
inputID: 1,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
{
name: "invalid access level",
inputID: 1,
inputAccesses: map[int]string{
1: "invalid_access",
},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "invalid_access"},
},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateEnvironmentTeamAccesses", tt.inputID, tt.inputAccesses).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateEnvironmentTeamAccesses()
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/validation errors")
if strings.Contains(tt.name, "invalid access level") {
assert.Contains(t, textContent.Text, "invalid team accesses")
}
}
} 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)
})
}
}
```
--------------------------------------------------------------------------------
/internal/mcp/stack_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 TestHandleGetStacks(t *testing.T) {
tests := []struct {
name string
mockStacks []models.Stack
mockError error
expectError bool
}{
{
name: "successful stacks retrieval",
mockStacks: []models.Stack{
{ID: 1, Name: "stack1"},
{ID: 2, Name: "stack2"},
},
mockError: nil,
expectError: false,
},
{
name: "api error",
mockStacks: nil,
mockError: fmt.Errorf("api error"),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
mockClient.On("GetStacks").Return(tt.mockStacks, tt.mockError)
server := &PortainerMCPServer{
cli: mockClient,
}
handler := server.HandleGetStacks()
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 stacks []models.Stack
err = json.Unmarshal([]byte(textContent.Text), &stacks)
assert.NoError(t, err)
assert.Equal(t, tt.mockStacks, stacks)
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleGetStackFile(t *testing.T) {
tests := []struct {
name string
inputID int
mockContent string
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful file retrieval",
inputID: 1,
mockContent: "version: '3'\nservices:\n web:\n image: nginx",
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
{
name: "api error",
inputID: 1,
mockContent: "",
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
{
name: "missing id parameter",
inputID: 0,
mockContent: "",
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("GetStackFile", tt.inputID).Return(tt.mockContent, tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleGetStackFile()
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.Equal(t, tt.mockContent, textContent.Text)
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleCreateStack(t *testing.T) {
tests := []struct {
name string
inputName string
inputFile string
inputEnvGroupIDs []int
mockID int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful stack creation",
inputName: "test-stack",
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: []int{1, 2},
mockID: 1,
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "test-stack",
"file": "version: '3'\nservices:\n web:\n image: nginx",
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "api error",
inputName: "test-stack",
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: []int{1, 2},
mockID: 0,
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "test-stack",
"file": "version: '3'\nservices:\n web:\n image: nginx",
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "missing name parameter",
inputName: "",
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: []int{1, 2},
mockID: 0,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"file": "version: '3'\nservices:\n web:\n image: nginx",
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "missing file parameter",
inputName: "test-stack",
inputFile: "",
inputEnvGroupIDs: []int{1, 2},
mockID: 0,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "test-stack",
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "missing environmentGroupIds parameter",
inputName: "test-stack",
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: nil,
mockID: 0,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "test-stack",
"file": "version: '3'\nservices:\n web:\n image: nginx",
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("CreateStack", tt.inputName, tt.inputFile, tt.inputEnvGroupIDs).Return(tt.mockID, tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleCreateStack()
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 TestHandleUpdateStack(t *testing.T) {
tests := []struct {
name string
inputID int
inputFile string
inputEnvGroupIDs []int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful stack update",
inputID: 1,
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: []int{1, 2},
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"file": "version: '3'\nservices:\n web:\n image: nginx",
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "api error",
inputID: 1,
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: []int{1, 2},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"file": "version: '3'\nservices:\n web:\n image: nginx",
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "missing id parameter",
inputID: 0,
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: []int{1, 2},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"file": "version: '3'\nservices:\n web:\n image: nginx",
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "missing file parameter",
inputID: 1,
inputFile: "",
inputEnvGroupIDs: []int{1, 2},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"environmentGroupIds": []any{float64(1), float64(2)},
}
},
},
{
name: "missing environmentGroupIds parameter",
inputID: 1,
inputFile: "version: '3'\nservices:\n web:\n image: nginx",
inputEnvGroupIDs: nil,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"file": "version: '3'\nservices:\n web:\n image: nginx",
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateStack", tt.inputID, tt.inputFile, tt.inputEnvGroupIDs).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateStack()
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)
})
}
}
```
--------------------------------------------------------------------------------
/tests/integration/access_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 (
testAccessGroupName = "test-access-group"
testAccessGroupNewName = "test-access-group-updated"
testTeamAccessGroupName = "test-team-for-access-group"
testUserAccessGroupName = "test-user-for-access-group"
testAccGroupPassword = "testpassword"
accGroupUserRoleStandard = 2 // Portainer API role ID for Standard User
accGroupEndpointName = "test-endpoint-for-access-group"
)
// prepareAccessGroupTestEnvironment creates test resources needed for access group tests
// including users, teams, and environments
func prepareAccessGroupTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int, int) {
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")
// Create a test user
testUserID, err := env.RawClient.CreateUser(testUserAccessGroupName, testAccGroupPassword, accGroupUserRoleStandard)
require.NoError(t, err, "Failed to create test user via raw client")
// Create a test team
testTeamID, err := env.RawClient.CreateTeam(testTeamAccessGroupName)
require.NoError(t, err, "Failed to create test team via raw client")
// Create a test environment
testEnvID, err := env.RawClient.CreateEdgeDockerEndpoint(accGroupEndpointName)
require.NoError(t, err, "Failed to create test environment via raw client")
return int(testUserID), int(testTeamID), int(testEnvID)
}
// TestAccessGroupManagement is an integration test suite that verifies the complete
// lifecycle of access group management in Portainer MCP. It tests creation, listing,
// name updates, user accesses, team accesses, and environment management.
func TestAccessGroupManagement(t *testing.T) {
env := helpers.NewTestEnv(t)
defer env.Cleanup(t)
// Prepare the test environment
testUserID, testTeamID, testEnvID := prepareAccessGroupTestEnvironment(t, env)
var testAccessGroupID int
// Subtest: Access Group Creation
// Verifies that:
// - A new access group can be created via the HandleCreateAccessGroup handler
// - The handler response indicates success with an ID
// - The created access group exists in Portainer when checked directly via Raw Client
t.Run("Access Group Creation", func(t *testing.T) {
handler := env.MCPServer.HandleCreateAccessGroup()
request := mcp.CreateMCPRequest(map[string]any{
"name": testAccessGroupName,
"environmentIds": []any{float64(testEnvID)},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to create access 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 and extract ID for later tests
assert.Contains(t, textContent.Text, "Access group created successfully with ID:", "Success message prefix mismatch")
// Verify by fetching access group directly via raw client
rawAccessGroup, err := env.RawClient.GetEndpointGroupByName(testAccessGroupName)
require.NoError(t, err, "Failed to get access group directly via raw client")
assert.Equal(t, testAccessGroupName, rawAccessGroup.Name, "Access group name mismatch")
// Extract group ID for subsequent tests
testAccessGroupID = int(rawAccessGroup.ID)
})
// Subtest: Access Groups Listing
// Verifies that:
// - The access group list can be retrieved via the HandleGetAccessGroups handler
// - The list contains the expected access group
// - The access group has the correct name and properties
t.Run("Access Groups Listing", func(t *testing.T) {
handler := env.MCPServer.HandleGetAccessGroups()
result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
require.NoError(t, err, "Failed to get access 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 retrievedAccessGroups []models.AccessGroup
err = json.Unmarshal([]byte(textContent.Text), &retrievedAccessGroups)
require.NoError(t, err, "Failed to unmarshal retrieved access groups")
require.Len(t, retrievedAccessGroups, 2, "Expected exactly two access groups after unmarshalling")
accessGroup := retrievedAccessGroups[1]
assert.Equal(t, testAccessGroupName, accessGroup.Name, "Access group name mismatch")
// Fetch the same access group directly via the client
rawAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
require.NoError(t, err, "Failed to get access group directly via client")
// Convert the raw access group to the expected AccessGroup model
rawEndpoints, err := env.RawClient.ListEndpoints()
require.NoError(t, err, "Failed to list endpoints")
expectedAccessGroup := models.ConvertEndpointGroupToAccessGroup(rawAccessGroup, rawEndpoints)
assert.Equal(t, expectedAccessGroup, accessGroup, "Access group mismatch between MCP handler and direct client call")
})
// Subtest: Access Group Name Update
// Verifies that:
// - An access group's name can be updated via the HandleUpdateAccessGroupName handler
// - The handler response indicates success
// - The access group name is actually updated when checked directly via Raw Client
t.Run("Access Group Name Update", func(t *testing.T) {
handler := env.MCPServer.HandleUpdateAccessGroupName()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testAccessGroupID),
"name": testAccessGroupNewName,
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update access 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, "Access group name updated successfully", "Success message mismatch")
// Verify by fetching access group directly via raw client
updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
require.NoError(t, err, "Failed to get access group directly via client")
assert.Equal(t, testAccessGroupNewName, updatedAccessGroup.Name, "Access group name was not updated")
})
// Subtest: Access Group User Accesses Update
// Verifies that:
// - User access policies can be updated via the HandleUpdateAccessGroupUserAccesses handler
// - The handler response indicates success
// - The access policies are correctly updated when checked directly via Raw Client
t.Run("Access Group User Accesses Update", func(t *testing.T) {
handler := env.MCPServer.HandleUpdateAccessGroupUserAccesses()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testAccessGroupID),
"userAccesses": []any{
map[string]any{"id": float64(testUserID), "access": "environment_administrator"},
},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update access group user accesses 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, "Access group user accesses updated successfully", "Success message mismatch")
// Verify by fetching access group directly via raw client and checking user accesses
updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
require.NoError(t, err, "Failed to get access group directly via client")
rawEndpoints, err := env.RawClient.ListEndpoints()
require.NoError(t, err, "Failed to list endpoints")
convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
userAccess, exists := convertedAccessGroup.UserAccesses[testUserID]
assert.True(t, exists, "User access policy not found")
assert.Equal(t, "environment_administrator", userAccess, "User access level mismatch")
})
// Subtest: Access Group Team Accesses Update
// Verifies that:
// - Team access policies can be updated via the HandleUpdateAccessGroupTeamAccesses handler
// - The handler response indicates success
// - The access policies are correctly updated when checked directly via Raw Client
t.Run("Access Group Team Accesses Update", func(t *testing.T) {
handler := env.MCPServer.HandleUpdateAccessGroupTeamAccesses()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testAccessGroupID),
"teamAccesses": []any{
map[string]any{"id": float64(testTeamID), "access": "standard_user"},
},
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to update access group team accesses 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, "Access group team accesses updated successfully", "Success message mismatch")
// Verify by fetching access group directly via raw client and checking team accesses
updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
require.NoError(t, err, "Failed to get access group directly via client")
rawEndpoints, err := env.RawClient.ListEndpoints()
require.NoError(t, err, "Failed to list endpoints")
convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
teamAccess, exists := convertedAccessGroup.TeamAccesses[testTeamID]
assert.True(t, exists, "Team access policy not found")
assert.Equal(t, "standard_user", teamAccess, "Team access level mismatch")
})
// Subtest: Remove Environment From Access Group
// Verifies that:
// - An environment can be removed from an access group via the HandleRemoveEnvironmentFromAccessGroup handler
// - The handler response indicates success
// - The environment is actually removed when checked directly via Raw Client
t.Run("Remove Environment From Access Group", func(t *testing.T) {
handler := env.MCPServer.HandleRemoveEnvironmentFromAccessGroup()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testAccessGroupID),
"environmentId": float64(testEnvID),
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to remove environment from access group 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 removed from access group successfully", "Success message mismatch")
// Verify by fetching access group directly via raw client and checking environments
updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
require.NoError(t, err, "Failed to get access group directly via client")
rawEndpoints, err := env.RawClient.ListEndpoints()
require.NoError(t, err, "Failed to list endpoints")
convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
assert.ElementsMatch(t, []int{}, convertedAccessGroup.EnvironmentIds, "Environment was not removed from access group")
})
// Subtest: Add Environment To Access Group
// Verifies that:
// - An environment can be added back to an access group via the HandleAddEnvironmentToAccessGroup handler
// - The handler response indicates success
// - The environment is actually added when checked directly via Raw Client
// Note: This test is run after the remove test to verify both operations work correctly
t.Run("Add Environment To Access Group", func(t *testing.T) {
handler := env.MCPServer.HandleAddEnvironmentToAccessGroup()
request := mcp.CreateMCPRequest(map[string]any{
"id": float64(testAccessGroupID),
"environmentId": float64(testEnvID),
})
result, err := handler(env.Ctx, request)
require.NoError(t, err, "Failed to add environment to access group 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 added to access group successfully", "Success message mismatch")
// Verify by fetching access group directly via raw client and checking environments
updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
require.NoError(t, err, "Failed to get access group directly via client")
rawEndpoints, err := env.RawClient.ListEndpoints()
require.NoError(t, err, "Failed to list endpoints")
convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
assert.ElementsMatch(t, []int{testEnvID}, convertedAccessGroup.EnvironmentIds, "Environment was not added to access group")
})
}
```
--------------------------------------------------------------------------------
/internal/mcp/group_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 TestHandleGetEnvironmentGroups(t *testing.T) {
tests := []struct {
name string
mockGroups []models.Group
mockError error
expectError bool
}{
{
name: "successful groups retrieval",
mockGroups: []models.Group{
{ID: 1, Name: "group1"},
{ID: 2, Name: "group2"},
},
mockError: nil,
expectError: false,
},
{
name: "api error",
mockGroups: nil,
mockError: fmt.Errorf("api error"),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
mockClient.On("GetEnvironmentGroups").Return(tt.mockGroups, tt.mockError)
server := &PortainerMCPServer{
cli: mockClient,
}
handler := server.HandleGetEnvironmentGroups()
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 groups []models.Group
err = json.Unmarshal([]byte(textContent.Text), &groups)
assert.NoError(t, err)
assert.Equal(t, tt.mockGroups, groups)
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleCreateEnvironmentGroup(t *testing.T) {
tests := []struct {
name string
inputName string
inputEnvIDs []int
mockID int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful group creation",
inputName: "group1",
inputEnvIDs: []int{1, 2, 3},
mockID: 1,
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "api error",
inputName: "group1",
inputEnvIDs: []int{1, 2, 3},
mockID: 0,
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "missing name parameter",
inputEnvIDs: []int{1, 2, 3},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "missing environmentIds parameter",
inputName: "group1",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("CreateEnvironmentGroup", tt.inputName, tt.inputEnvIDs).Return(tt.mockID, tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleCreateEnvironmentGroup()
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 TestHandleUpdateEnvironmentGroupName(t *testing.T) {
tests := []struct {
name string
inputID int
inputName string
mockError error
expectError bool
setupParams func(request mcp.CallToolRequest) mcp.CallToolRequest
}{
{
name: "successful name update",
inputID: 1,
inputName: "newname",
mockError: nil,
expectError: false,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
"name": "newname",
}
return request
},
},
{
name: "api error",
inputID: 1,
inputName: "newname",
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
"name": "newname",
}
return request
},
},
{
name: "missing id parameter",
inputName: "newname",
mockError: nil,
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"name": "newname",
}
return request
},
},
{
name: "missing name parameter",
inputID: 1,
mockError: nil,
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
return request
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateEnvironmentGroupName", tt.inputID, tt.inputName).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
request = tt.setupParams(request)
handler := server.HandleUpdateEnvironmentGroupName()
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 TestHandleUpdateEnvironmentGroupEnvironments(t *testing.T) {
tests := []struct {
name string
inputID int
inputEnvIDs []int
mockError error
expectError bool
setupParams func(request mcp.CallToolRequest) mcp.CallToolRequest
}{
{
name: "successful environments update",
inputID: 1,
inputEnvIDs: []int{1, 2, 3},
mockError: nil,
expectError: false,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
return request
},
},
{
name: "api error",
inputID: 1,
inputEnvIDs: []int{1, 2, 3},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
return request
},
},
{
name: "missing id parameter",
inputEnvIDs: []int{1, 2, 3},
mockError: nil,
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
return request
},
},
{
name: "missing environmentIds parameter",
inputID: 1,
mockError: nil,
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
"name": "group1",
}
return request
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateEnvironmentGroupEnvironments", tt.inputID, tt.inputEnvIDs).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
request = tt.setupParams(request)
handler := server.HandleUpdateEnvironmentGroupEnvironments()
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 TestHandleUpdateEnvironmentGroupTags(t *testing.T) {
tests := []struct {
name string
inputID int
inputTagIDs []int
mockError error
expectError bool
setupParams func(request mcp.CallToolRequest) mcp.CallToolRequest
}{
{
name: "successful tags update",
inputID: 1,
inputTagIDs: []int{1, 2, 3},
mockError: nil,
expectError: false,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
"tagIds": []any{float64(1), float64(2), float64(3)},
}
return request
},
},
{
name: "api error",
inputID: 1,
inputTagIDs: []int{1, 2, 3},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
"tagIds": []any{float64(1), float64(2), float64(3)},
}
return request
},
},
{
name: "missing id parameter",
inputTagIDs: []int{1, 2, 3},
mockError: nil,
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"tagIds": []any{float64(1), float64(2), float64(3)},
}
return request
},
},
{
name: "missing tagIds parameter",
inputID: 1,
mockError: nil,
expectError: true,
setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
return request
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("UpdateEnvironmentGroupTags", tt.inputID, tt.inputTagIDs).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
request = tt.setupParams(request)
handler := server.HandleUpdateEnvironmentGroupTags()
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/toolgen/yaml_test.go:
--------------------------------------------------------------------------------
```go
package toolgen
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
)
func TestLoadToolsFromYAML(t *testing.T) {
// Create a minimal test YAML file
tmpDir := t.TempDir()
validYamlPath := filepath.Join(tmpDir, "valid.yaml")
validYamlContent := `version: "v1.0.0"
tools:
- name: testTool
description: A test tool
parameters:
- name: param1
type: string
required: true
description: A test parameter
annotations:
title: Test Tool Title
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false`
err := os.WriteFile(validYamlPath, []byte(validYamlContent), 0644)
if err != nil {
t.Fatalf("Failed to create test YAML file: %v", err)
}
// Create a newer version YAML file
newerVersionPath := filepath.Join(tmpDir, "newer.yaml")
newerVersionContent := `version: "v1.2.0"
tools:
- name: testTool
description: A test tool
parameters:
- name: param1
type: string
required: true
description: A test parameter
annotations:
title: Test Tool Title
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false`
err = os.WriteFile(newerVersionPath, []byte(newerVersionContent), 0644)
if err != nil {
t.Fatalf("Failed to create newer version YAML file: %v", err)
}
// Create an older version YAML file (will fail version check)
olderVersionPath := filepath.Join(tmpDir, "older.yaml")
olderVersionContent := `version: "v0.9.0"
tools:
- name: testTool
description: A test tool
parameters:
- name: param1
type: string
required: true
description: A test parameter
# Annotations potentially missing, but version check fails first
`
err = os.WriteFile(olderVersionPath, []byte(olderVersionContent), 0644)
if err != nil {
t.Fatalf("Failed to create older version YAML file: %v", err)
}
// Create a file with missing version (will fail version check)
missingVersionPath := filepath.Join(tmpDir, "missing_version.yaml")
missingVersionContent := `tools:
- name: testTool
description: A test tool
parameters:
- name: param1
type: string
required: true
description: A test parameter
# Annotations potentially missing, but version check fails first
`
err = os.WriteFile(missingVersionPath, []byte(missingVersionContent), 0644)
if err != nil {
t.Fatalf("Failed to create missing version YAML file: %v", err)
}
// Create a file with invalid version format (will fail version check)
invalidVersionPath := filepath.Join(tmpDir, "invalid_version.yaml")
invalidVersionContent := `version: "1.0"
tools:
- name: testTool
description: A test tool
parameters:
- name: param1
type: string
required: true
description: A test parameter
# Annotations potentially missing, but version check fails first
`
err = os.WriteFile(invalidVersionPath, []byte(invalidVersionContent), 0644)
if err != nil {
t.Fatalf("Failed to create invalid version YAML file: %v", err)
}
// Create a file with missing annotations block (should fail annotation check)
missingAnnotationsPath := filepath.Join(tmpDir, "missing_annotations.yaml")
missingAnnotationsContent := `version: "v1.0.0"
tools:
- name: toolWithoutAnnotations
description: A test tool missing annotations
parameters:
- name: param1
type: string
required: true
description: A test parameter
- name: toolWithAnnotations
description: A test tool with annotations
annotations:
title: Some Title
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
`
err = os.WriteFile(missingAnnotationsPath, []byte(missingAnnotationsContent), 0644)
if err != nil {
t.Fatalf("Failed to create missing annotations YAML file: %v", err)
}
tests := []struct {
name string
filePath string
minimumVersion string
wantErr bool
wantTool string // name of tool we expect to find
wantToolCount int // expected number of tools loaded
}{
{
name: "valid yaml file",
filePath: validYamlPath,
minimumVersion: "v1.0.0",
wantErr: false,
wantTool: "testTool",
wantToolCount: 1,
},
{
name: "valid yaml file with newer minimum version",
filePath: validYamlPath,
minimumVersion: "v1.1.0",
wantErr: true, // Error because file version is below minimum
},
{
name: "newer version yaml file",
filePath: newerVersionPath,
minimumVersion: "v1.0.0",
wantErr: false,
wantTool: "testTool",
wantToolCount: 1,
},
{
name: "older version yaml file",
filePath: olderVersionPath,
minimumVersion: "v1.0.0",
wantErr: true, // Error because file version is below minimum
},
{
name: "missing version in yaml",
filePath: missingVersionPath,
minimumVersion: "v1.0.0",
wantErr: true,
},
{
name: "invalid version format",
filePath: invalidVersionPath,
minimumVersion: "v1.0.0",
wantErr: true, // Error because version format is invalid
},
{
name: "missing annotations block",
filePath: missingAnnotationsPath,
minimumVersion: "v1.0.0",
wantErr: false, // LoadToolsFromYAML itself doesn't error, but skips the invalid tool
wantTool: "toolWithAnnotations", // Only the tool with annotations should load
wantToolCount: 1, // Expect only one tool to be loaded successfully
},
{
name: "non-existent file",
filePath: "nonexistent.yaml",
minimumVersion: "v1.0.0",
wantErr: true,
},
{
name: "invalid yaml content",
filePath: createInvalidYAMLFile(t),
minimumVersion: "v1.0.0",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tools, err := LoadToolsFromYAML(tt.filePath, tt.minimumVersion)
if (err != nil) != tt.wantErr {
t.Errorf("LoadToolsFromYAML() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if len(tools) != tt.wantToolCount {
t.Errorf("LoadToolsFromYAML() loaded %d tools, want %d", len(tools), tt.wantToolCount)
}
if tt.wantTool != "" {
tool, exists := tools[tt.wantTool]
if !exists {
t.Errorf("Expected tool '%s' not found in loaded tools: %v", tt.wantTool, tools)
return
}
if tool.Name != tt.wantTool {
t.Errorf("Tool name mismatch, got %s, want %s", tool.Name, tt.wantTool)
}
if tool.Description == "" {
t.Errorf("Tool %s has no description", tt.wantTool)
}
// Basic check to ensure annotations were processed (more detailed checks in TestConvertToolDefinition)
if tool.Annotations.Title == "" { // Check a field within Annotations
t.Errorf("Tool %s seems to be missing processed annotations", tt.wantTool)
}
}
}
})
}
}
// Helper function to create an invalid YAML file for testing
func createInvalidYAMLFile(t *testing.T) string {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "invalid.yaml")
// Add annotations to avoid failing that check first
content := `version: "v1.0.0"
tools:
- name: invalid
description: [invalid yaml content
annotations:
title: Invalid Tool`
err := os.WriteFile(path, []byte(content), 0644)
if err != nil {
t.Fatalf("Failed to create invalid YAML file: %v", err)
}
return path
}
func TestConvertToolDefinition(t *testing.T) {
// Define a valid annotation struct to reuse
validAnnotations := Annotations{
Title: "Valid Title",
ReadOnlyHint: true,
DestructiveHint: false,
IdempotentHint: true,
OpenWorldHint: false,
}
tests := []struct {
name string
def ToolDefinition
wantErr bool
wantErrSubstr string // Optional: check for specific error message content
want *mcp.ToolAnnotation // Expected annotation output
}{
{
name: "valid tool definition",
def: ToolDefinition{
Name: "validTool",
Description: "A valid tool description",
Annotations: validAnnotations,
},
wantErr: false,
want: &mcp.ToolAnnotation{
Title: "Valid Title",
ReadOnlyHint: &validAnnotations.ReadOnlyHint,
DestructiveHint: &validAnnotations.DestructiveHint,
IdempotentHint: &validAnnotations.IdempotentHint,
OpenWorldHint: &validAnnotations.OpenWorldHint,
},
},
{
name: "empty name",
def: ToolDefinition{
Name: "",
Description: "A tool with empty name",
Annotations: validAnnotations, // Needs annotations even if name is invalid
},
wantErr: true,
wantErrSubstr: "tool name is required",
},
{
name: "empty description",
def: ToolDefinition{
Name: "noDescTool",
Description: "",
Annotations: validAnnotations, // Needs annotations even if desc is invalid
},
wantErr: true,
wantErrSubstr: "tool description is required",
},
{
name: "missing annotations",
def: ToolDefinition{
Name: "noAnnotationTool",
Description: "Tool without annotations",
Annotations: Annotations{}, // Zero value simulates missing block
},
wantErr: true,
wantErrSubstr: "annotations block is required",
},
{
name: "with parameters",
def: ToolDefinition{
Name: "paramTool",
Description: "Tool with parameters",
Parameters: []ParameterDefinition{
{
Name: "param1",
Type: "string",
Required: true,
Description: "A test parameter",
},
},
Annotations: validAnnotations,
},
wantErr: false,
want: &mcp.ToolAnnotation{
Title: "Valid Title",
ReadOnlyHint: &validAnnotations.ReadOnlyHint,
DestructiveHint: &validAnnotations.DestructiveHint,
IdempotentHint: &validAnnotations.IdempotentHint,
OpenWorldHint: &validAnnotations.OpenWorldHint,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tool, err := convertToolDefinition(tt.def)
if tt.wantErr {
assert.Error(t, err)
if tt.wantErrSubstr != "" {
assert.Contains(t, err.Error(), tt.wantErrSubstr)
}
} else {
assert.NoError(t, err)
assert.Equal(t, tt.def.Name, tool.Name)
assert.Equal(t, tt.def.Description, tool.Description)
assert.Equal(t, *tt.want, tool.Annotations)
}
})
}
}
func TestConvertToolDefinitions(t *testing.T) {
// Define a valid annotation struct to reuse
validAnnotations := Annotations{
Title: "Valid Title",
ReadOnlyHint: true,
DestructiveHint: false,
IdempotentHint: true,
OpenWorldHint: false,
}
tests := []struct {
name string
defs []ToolDefinition
want int // number of tools expected to be successfully converted
}{
{
name: "empty definitions",
defs: []ToolDefinition{},
want: 0,
},
{
name: "single valid tool",
defs: []ToolDefinition{
{
Name: "tool1",
Description: "Test tool 1",
Parameters: []ParameterDefinition{
{
Name: "param1",
Type: "string",
Required: true,
Description: "Test parameter",
},
},
Annotations: validAnnotations,
},
},
want: 1,
},
{
name: "multiple valid tools",
defs: []ToolDefinition{
{
Name: "tool1",
Description: "Test tool 1",
Annotations: validAnnotations,
},
{
Name: "tool2",
Description: "Test tool 2",
Annotations: validAnnotations,
},
},
want: 2,
},
{
name: "invalid tools are skipped",
defs: []ToolDefinition{
{
Name: "validTool1",
Description: "Test tool 1",
Annotations: validAnnotations,
},
{
Name: "", // Invalid: empty name
Description: "Tool with empty name",
Annotations: validAnnotations,
},
{
Name: "noDescTool", // Invalid: empty description
Description: "",
Annotations: validAnnotations,
},
{
Name: "noAnnotationTool", // Invalid: missing annotations
Description: "Tool missing annotations",
Annotations: Annotations{}, // Zero value
},
{
Name: "validTool2",
Description: "Test tool 2",
Annotations: validAnnotations,
},
},
want: 2, // Only 2 valid tools should be returned
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertToolDefinitions(tt.defs)
assert.Len(t, got, tt.want)
// Verify each tool expected to be converted exists and is valid
for _, def := range tt.defs {
// Skip definitions that are expected to cause errors
if def.Name == "" || def.Description == "" || (def.Annotations == Annotations{}) {
continue
}
tool, exists := got[def.Name]
assert.True(t, exists, "Tool %s not found in result", def.Name)
if exists {
assert.Equal(t, def.Name, tool.Name)
assert.Equal(t, def.Description, tool.Description)
assert.NotEmpty(t, tool.Annotations.Title) // Basic check that title is populated
}
}
})
}
}
func TestConvertParameter(t *testing.T) {
tests := []struct {
name string
param ParameterDefinition
want reflect.Type // We'll check the type of the returned option
}{
{
name: "string parameter",
param: ParameterDefinition{
Name: "strParam",
Type: "string",
Required: true,
Description: "A string parameter",
},
want: reflect.TypeOf(mcp.WithString("", mcp.Description(""))),
},
{
name: "number parameter",
param: ParameterDefinition{
Name: "numParam",
Type: "number",
Required: true,
Description: "A number parameter",
},
want: reflect.TypeOf(mcp.WithNumber("", mcp.Description(""))),
},
{
name: "boolean parameter",
param: ParameterDefinition{
Name: "boolParam",
Type: "boolean",
Required: true,
Description: "A boolean parameter",
},
want: reflect.TypeOf(mcp.WithBoolean("", mcp.Description(""))),
},
{
name: "array parameter",
param: ParameterDefinition{
Name: "arrayParam",
Type: "array",
Required: true,
Description: "An array parameter",
Items: map[string]any{
"type": "string",
},
},
want: reflect.TypeOf(mcp.WithArray("", mcp.Description(""))),
},
{
name: "object parameter",
param: ParameterDefinition{
Name: "objParam",
Type: "object",
Required: true,
Description: "An object parameter",
Items: map[string]any{
"type": "object",
"properties": map[string]any{
"key": map[string]any{
"type": "string",
},
},
},
},
want: reflect.TypeOf(mcp.WithObject("", mcp.Description(""))),
},
{
name: "enum parameter",
param: ParameterDefinition{
Name: "enumParam",
Type: "string",
Required: true,
Description: "An enum parameter",
Enum: []string{"val1", "val2"},
},
want: reflect.TypeOf(mcp.WithString("", mcp.Description(""))),
},
{
name: "unknown type parameter",
param: ParameterDefinition{
Name: "unknownParam",
Type: "unknown",
Required: true,
Description: "An unknown parameter",
},
want: reflect.TypeOf(mcp.WithString("", mcp.Description(""))), // defaults to string
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertParameter(tt.param)
gotType := reflect.TypeOf(got)
if gotType != tt.want {
t.Errorf("convertParameter() returned %v, want %v", gotType, tt.want)
}
})
}
}
// Optional: Add a specific test for convertAnnotation if desired, though it's simple
func TestConvertAnnotation(t *testing.T) {
input := Annotations{
Title: "Test Title",
ReadOnlyHint: true,
DestructiveHint: true,
IdempotentHint: false,
OpenWorldHint: false,
}
want := mcp.ToolAnnotation{
Title: "Test Title",
ReadOnlyHint: &input.ReadOnlyHint,
DestructiveHint: &input.DestructiveHint,
IdempotentHint: &input.IdempotentHint,
OpenWorldHint: &input.OpenWorldHint,
}
dummyTool := &mcp.Tool{}
option := convertAnnotation(input)
option(dummyTool)
assert.NotNil(t, option)
assert.Equal(t, want, dummyTool.Annotations)
}
```
--------------------------------------------------------------------------------
/internal/mcp/kubernetes_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"errors"
"net/http"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestHandleKubernetesProxy_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),
"kubernetesAPIPath": "/api/v1/pods",
"method": "POST",
"body": true, // Invalid type for body
},
expectedErrorMsg: "body must be a string",
},
{
name: "missing environmentId",
inputParams: map[string]any{
"kubernetesAPIPath": "/api/v1/pods",
"method": "GET",
},
expectedErrorMsg: "environmentId is required",
},
{
name: "missing kubernetesAPIPath",
inputParams: map[string]any{
"environmentId": float64(1),
"method": "GET",
},
expectedErrorMsg: "kubernetesAPIPath is required",
},
{
name: "missing method",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
},
expectedErrorMsg: "method is required",
},
{
name: "invalid kubernetesAPIPath (no leading slash)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "api/v1/pods",
"method": "GET",
},
expectedErrorMsg: "kubernetesAPIPath must start with a leading slash",
},
{
name: "invalid HTTP method",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"method": "INVALID",
},
expectedErrorMsg: "invalid method: INVALID",
},
{
name: "invalid queryParams type (not an array)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"method": "GET",
"queryParams": "not-an-array",
},
expectedErrorMsg: "queryParams must be an array",
},
{
name: "invalid queryParams content (value not string)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"method": "GET",
"queryParams": []any{map[string]any{"key": "namespace", "value": false}},
},
expectedErrorMsg: "invalid query params: invalid value: false",
},
{
name: "invalid headers type (not an array)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"method": "GET",
"headers": "header-string",
},
expectedErrorMsg: "headers must be an array",
},
{
name: "invalid headers content (missing value)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"method": "GET",
"headers": []any{map[string]any{"key": "Content-Type"}},
},
expectedErrorMsg: "invalid headers: invalid value: <nil>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &PortainerMCPServer{} // No client needed for param validation
request := CreateMCPRequest(tt.inputParams)
handler := server.HandleKubernetesProxy()
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 TestHandleKubernetesProxy_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 with query params",
input: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"method": "GET",
"queryParams": []any{
map[string]any{"key": "namespace", "value": "default"},
map[string]any{"key": "labelSelector", "value": "app=myApp"},
},
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusOK, `{"kind":"PodList","items":[]}`),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
resultText: `{"kind":"PodList","items":[]}`,
},
},
{
name: "successful POST request with body and headers",
input: map[string]any{
"environmentId": float64(2),
"kubernetesAPIPath": "/api/v1/namespaces/test/services",
"method": "POST",
"body": `{"apiVersion":"v1","kind":"Service","metadata":{"name":"my-service"}}`,
"headers": []any{
map[string]any{"key": "Content-Type", "value": "application/json"},
},
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusCreated, `{"metadata":{"name":"my-service"}}`),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
resultText: `{"metadata":{"name":"my-service"}}`,
},
},
{
name: "client API error",
input: map[string]any{
"environmentId": float64(3),
"kubernetesAPIPath": "/version",
"method": "GET",
},
mock: struct {
response *http.Response
err error
}{
response: nil,
err: errors.New("k8s api error"),
},
expect: struct {
errSubstring string
resultText string
}{
errSubstring: "failed to send Kubernetes API request: k8s api error",
},
},
{
name: "error reading response body",
input: map[string]any{
"environmentId": float64(4),
"kubernetesAPIPath": "/healthz",
"method": "GET",
},
mock: struct {
response *http.Response
err error
}{
response: &http.Response{
StatusCode: http.StatusOK,
Body: &errorReader{}, // Simulate read error
},
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
errSubstring: "failed to read Kubernetes API response: simulated read error",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mockClient := new(MockPortainerClient)
mockClient.On("ProxyKubernetesRequest", mock.AnythingOfType("models.KubernetesProxyRequestOptions")).
Return(tc.mock.response, tc.mock.err)
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(tc.input)
handler := server.HandleKubernetesProxy()
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)
})
}
}
func TestHandleKubernetesProxyStripped_ParameterValidation(t *testing.T) {
tests := []struct {
name string
inputParams map[string]any
expectedErrorMsg string
}{
{
name: "missing environmentId",
inputParams: map[string]any{
"kubernetesAPIPath": "/api/v1/pods",
},
expectedErrorMsg: "environmentId is required",
},
{
name: "missing kubernetesAPIPath",
inputParams: map[string]any{
"environmentId": float64(1),
},
expectedErrorMsg: "kubernetesAPIPath is required",
},
{
name: "invalid kubernetesAPIPath (no leading slash)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "api/v1/pods",
},
expectedErrorMsg: "kubernetesAPIPath must start with a leading slash",
},
{
name: "invalid queryParams type (not an array)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"queryParams": "not-an-array",
},
expectedErrorMsg: "queryParams must be an array",
},
{
name: "invalid queryParams content (value not string)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"queryParams": []any{map[string]any{"key": "namespace", "value": false}},
},
expectedErrorMsg: "invalid query params: invalid value: false",
},
{
name: "invalid headers type (not an array)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"headers": "header-string",
},
expectedErrorMsg: "headers must be an array",
},
{
name: "invalid headers content (missing value)",
inputParams: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"headers": []any{map[string]any{"key": "Content-Type"}},
},
expectedErrorMsg: "invalid headers: invalid value: <nil>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &PortainerMCPServer{} // No client needed for param validation
request := CreateMCPRequest(tt.inputParams)
handler := server.HandleKubernetesProxyStripped()
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 TestHandleKubernetesProxyStripped_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 with managedFields stripped",
input: map[string]any{
"environmentId": float64(1),
"kubernetesAPIPath": "/api/v1/pods",
"queryParams": []any{
map[string]any{"key": "namespace", "value": "default"},
map[string]any{"key": "labelSelector", "value": "app=myApp"},
},
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusOK, `{
"apiVersion": "v1",
"kind": "PodList",
"items": [
{
"apiVersion": "v1",
"kind": "Pod",
"metadata": {
"name": "test-pod-1",
"namespace": "default",
"managedFields": [
{
"manager": "kubectl-client-side-apply",
"operation": "Update",
"apiVersion": "v1",
"time": "2023-01-01T00:00:00Z"
}
]
},
"spec": {
"containers": [
{
"name": "test-container",
"image": "nginx"
}
]
}
}
]
}`),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
resultText: `{"apiVersion":"v1","items":[{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod-1","namespace":"default"},"spec":{"containers":[{"image":"nginx","name":"test-container"}]}}],"kind":"PodList"}`,
},
},
{
name: "successful GET request with headers",
input: map[string]any{
"environmentId": float64(2),
"kubernetesAPIPath": "/api/v1/namespaces/default/pods",
"headers": []any{
map[string]any{"key": "X-Custom-Header", "value": "test-value"},
map[string]any{"key": "Authorization", "value": "Bearer abc"},
},
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusOK, `{
"apiVersion": "v1",
"kind": "Pod",
"metadata": {
"name": "test-pod",
"namespace": "default",
"managedFields": [
{
"manager": "kubectl-client-side-apply",
"operation": "Update",
"apiVersion": "v1",
"time": "2023-01-01T00:00:00Z"
}
]
},
"spec": {
"containers": [
{
"name": "test-container",
"image": "nginx"
}
]
}
}`),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
resultText: `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod","namespace":"default"},"spec":{"containers":[{"image":"nginx","name":"test-container"}]}}`,
},
},
{
name: "client API error",
input: map[string]any{
"environmentId": float64(3),
"kubernetesAPIPath": "/version",
},
mock: struct {
response *http.Response
err error
}{
response: nil,
err: errors.New("k8s api error"),
},
expect: struct {
errSubstring string
resultText string
}{
errSubstring: "failed to send Kubernetes API request: k8s api error",
},
},
{
name: "error processing response body",
input: map[string]any{
"environmentId": float64(4),
"kubernetesAPIPath": "/healthz",
},
mock: struct {
response *http.Response
err error
}{
response: &http.Response{
StatusCode: http.StatusOK,
Body: &errorReader{}, // Simulate read error
},
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
errSubstring: "failed to process Kubernetes API response: failed to read response body: simulated read error",
},
},
{
name: "empty response body",
input: map[string]any{
"environmentId": float64(5),
"kubernetesAPIPath": "/api/v1/namespaces",
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusNoContent, ""),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
resultText: "",
},
},
{
name: "invalid JSON response",
input: map[string]any{
"environmentId": float64(6),
"kubernetesAPIPath": "/api/v1/pods",
},
mock: struct {
response *http.Response
err error
}{
response: createMockHttpResponse(http.StatusOK, "invalid json"),
err: nil,
},
expect: struct {
errSubstring string
resultText string
}{
errSubstring: "failed to process Kubernetes API response: failed to unmarshal JSON into Unstructured",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mockClient := new(MockPortainerClient)
mockClient.On("ProxyKubernetesRequest", mock.AnythingOfType("models.KubernetesProxyRequestOptions")).
Return(tc.mock.response, tc.mock.err)
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(tc.input)
handler := server.HandleKubernetesProxyStripped()
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)
if tc.expect.resultText == "" {
assert.Equal(t, tc.expect.resultText, textContent.Text)
} else {
assert.JSONEq(t, tc.expect.resultText, textContent.Text)
}
}
mockClient.AssertExpectations(t)
})
}
}
```
--------------------------------------------------------------------------------
/internal/mcp/access_group_test.go:
--------------------------------------------------------------------------------
```go
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/portainer/portainer-mcp/pkg/portainer/models"
"github.com/stretchr/testify/assert"
)
func TestHandleGetAccessGroups(t *testing.T) {
tests := []struct {
name string
mockGroups []models.AccessGroup
mockError error
expectError bool
}{
{
name: "successful groups retrieval",
mockGroups: []models.AccessGroup{
{ID: 1, Name: "group1"},
{ID: 2, Name: "group2"},
},
mockError: nil,
expectError: false,
},
{
name: "api error",
mockGroups: nil,
mockError: fmt.Errorf("api error"),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
mockClient.On("GetAccessGroups").Return(tt.mockGroups, tt.mockError)
server := &PortainerMCPServer{
cli: mockClient,
}
handler := server.HandleGetAccessGroups()
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 groups []models.AccessGroup
err = json.Unmarshal([]byte(textContent.Text), &groups)
assert.NoError(t, err)
assert.Equal(t, tt.mockGroups, groups)
}
mockClient.AssertExpectations(t)
})
}
}
func TestHandleCreateAccessGroup(t *testing.T) {
tests := []struct {
name string
inputName string
inputEnvIDs []int
mockID int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful group creation",
inputName: "group1",
inputEnvIDs: []int{1, 2, 3},
mockID: 1,
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "api error",
inputName: "group1",
inputEnvIDs: []int{1, 2, 3},
mockID: 0,
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "missing name parameter",
inputEnvIDs: []int{1, 2, 3},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"environmentIds": []any{float64(1), float64(2), float64(3)},
}
},
},
{
name: "invalid environmentIds - not an array",
inputName: "group1",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
"environmentIds": "not an array",
}
},
},
{
name: "invalid environmentIds - array with non-numbers",
inputName: "group1",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
"environmentIds": []any{"1", "2", "3"},
}
},
},
{
name: "invalid environmentIds - array with mixed types",
inputName: "group1",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "group1",
"environmentIds": []any{float64(1), "2", float64(3)},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
mockClient.On("CreateAccessGroup", tt.inputName, tt.inputEnvIDs).Return(tt.mockID, tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleCreateAccessGroup()
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 TestHandleUpdateAccessGroupName(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: "newname",
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"name": "newname",
}
},
},
{
name: "api error",
inputID: 1,
inputName: "newname",
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"name": "newname",
}
},
},
{
name: "missing id parameter",
inputName: "newname",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"name": "newname",
}
},
},
{
name: "missing name parameter",
inputID: 1,
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("UpdateAccessGroupName", tt.inputID, tt.inputName).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateAccessGroupName()
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 TestHandleUpdateAccessGroupUserAccesses(t *testing.T) {
tests := []struct {
name string
inputID int
inputAccesses []map[string]any
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful user accesses update",
inputID: 1,
inputAccesses: []map[string]any{
{"id": float64(1), "access": "environment_administrator"},
{"id": float64(2), "access": "standard_user"},
},
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
map[string]any{"id": float64(2), "access": "standard_user"},
},
}
},
},
{
name: "api error",
inputID: 1,
inputAccesses: []map[string]any{
{"id": float64(1), "access": "environment_administrator"},
},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing id parameter",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing userAccesses parameter",
inputID: 1,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
{
name: "invalid access level",
inputID: 1,
inputAccesses: []map[string]any{
{"id": float64(1), "access": "invalid_access"},
},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"userAccesses": []any{
map[string]any{"id": float64(1), "access": "invalid_access"},
},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
expectedMap := make(map[int]string)
for _, access := range tt.inputAccesses {
id := int(access["id"].(float64))
expectedMap[id] = access["access"].(string)
}
mockClient.On("UpdateAccessGroupUserAccesses", tt.inputID, expectedMap).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateAccessGroupUserAccesses()
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/validation errors")
if strings.Contains(tt.name, "invalid access level") {
assert.Contains(t, textContent.Text, "invalid user accesses")
}
}
} 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 TestHandleUpdateAccessGroupTeamAccesses(t *testing.T) {
tests := []struct {
name string
inputID int
inputAccesses []map[string]any
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful team accesses update",
inputID: 1,
inputAccesses: []map[string]any{
{"id": float64(1), "access": "environment_administrator"},
{"id": float64(2), "access": "standard_user"},
},
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
map[string]any{"id": float64(2), "access": "standard_user"},
},
}
},
},
{
name: "api error",
inputID: 1,
inputAccesses: []map[string]any{
{"id": float64(1), "access": "environment_administrator"},
},
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing id parameter",
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "environment_administrator"},
},
}
},
},
{
name: "missing teamAccesses parameter",
inputID: 1,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
}
},
},
{
name: "invalid access level",
inputID: 1,
inputAccesses: []map[string]any{
{"id": float64(1), "access": "invalid_access"},
},
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"teamAccesses": []any{
map[string]any{"id": float64(1), "access": "invalid_access"},
},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockPortainerClient{}
if !tt.expectError || tt.mockError != nil {
expectedMap := make(map[int]string)
for _, access := range tt.inputAccesses {
id := int(access["id"].(float64))
expectedMap[id] = access["access"].(string)
}
mockClient.On("UpdateAccessGroupTeamAccesses", tt.inputID, expectedMap).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleUpdateAccessGroupTeamAccesses()
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/validation errors")
if strings.Contains(tt.name, "invalid access level") {
assert.Contains(t, textContent.Text, "invalid team accesses")
}
}
} 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 TestHandleAddEnvironmentToAccessGroup(t *testing.T) {
tests := []struct {
name string
inputID int
inputEnvID int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful environment addition",
inputID: 1,
inputEnvID: 2,
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"environmentId": float64(2),
}
},
},
{
name: "api error",
inputID: 1,
inputEnvID: 2,
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"environmentId": float64(2),
}
},
},
{
name: "missing id parameter",
inputEnvID: 2,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"environmentId": float64(2),
}
},
},
{
name: "missing environmentId parameter",
inputID: 1,
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("AddEnvironmentToAccessGroup", tt.inputID, tt.inputEnvID).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleAddEnvironmentToAccessGroup()
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 TestHandleRemoveEnvironmentFromAccessGroup(t *testing.T) {
tests := []struct {
name string
inputID int
inputEnvID int
mockError error
expectError bool
setupParams func(request *mcp.CallToolRequest)
}{
{
name: "successful environment removal",
inputID: 1,
inputEnvID: 2,
mockError: nil,
expectError: false,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"environmentId": float64(2),
}
},
},
{
name: "api error",
inputID: 1,
inputEnvID: 2,
mockError: fmt.Errorf("api error"),
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"id": float64(1),
"environmentId": float64(2),
}
},
},
{
name: "missing id parameter",
inputEnvID: 2,
mockError: nil,
expectError: true,
setupParams: func(request *mcp.CallToolRequest) {
request.Params.Arguments = map[string]any{
"environmentId": float64(2),
}
},
},
{
name: "missing environmentId parameter",
inputID: 1,
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("RemoveEnvironmentFromAccessGroup", tt.inputID, tt.inputEnvID).Return(tt.mockError)
}
server := &PortainerMCPServer{
cli: mockClient,
}
request := CreateMCPRequest(map[string]any{})
tt.setupParams(&request)
handler := server.HandleRemoveEnvironmentFromAccessGroup()
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)
})
}
}
```
--------------------------------------------------------------------------------
/internal/tooldef/tools.yaml:
--------------------------------------------------------------------------------
```yaml
---
version: v1.2
tools:
## Access Groups
## An access group is the equivalent of an Endpoint Group in Portainer.
## ------------------------------------------------------------
- name: listAccessGroups
description: List all available access groups
annotations:
title: List Access Groups
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: createAccessGroup
description: Create a new access group. Use access groups when you want to define
accesses on more than one environment. Otherwise, define the accesses on
the environment level.
parameters:
- name: name
description: The name of the access group
type: string
required: true
- name: environmentIds
description: "The IDs of the environments that are part of the access group.
Must include all the environment IDs that are part of the group - this
includes new environments and the existing environments that are
already associated with the group. Example: [1, 2, 3]"
type: array
items:
type: number
annotations:
title: Create Access Group
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: updateAccessGroupName
description: Update the name of an existing access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: name
description: The name of the access group
type: string
required: true
annotations:
title: Update Access Group Name
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateAccessGroupUserAccesses
description: Update the user accesses of an existing access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: userAccesses
description: "The user accesses that are associated with all the environments in
the access group. The ID is the user ID of the user in Portainer.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
access: 'standard_user'}]"
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the user
type: number
access:
description: The access level of the user. Can be environment_administrator,
helpdesk_user, standard_user, readonly_user or operator_user
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Access Group User Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateAccessGroupTeamAccesses
description: Update the team accesses of an existing access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: teamAccesses
description: "The team accesses that are associated with all the environments in
the access group. The ID is the team ID of the team in Portainer.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
access: 'standard_user'}]"
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the team
type: number
access:
description: The access level of the team. Can be environment_administrator,
helpdesk_user, standard_user, readonly_user or operator_user
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Access Group Team Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: addEnvironmentToAccessGroup
description: Add an environment to an access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: environmentId
description: The ID of the environment to add to the access group
type: number
required: true
annotations:
title: Add Environment To Access Group
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: removeEnvironmentFromAccessGroup
description: Remove an environment from an access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: environmentId
description: The ID of the environment to remove from the access group
type: number
required: true
annotations:
title: Remove Environment From Access Group
readOnlyHint: false
destructiveHint: true
idempotentHint: true
openWorldHint: false
## Environment
## ------------------------------------------------------------
- name: listEnvironments
description: List all available environments
annotations:
title: List Environments
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentTags
description: Update the tags associated with an environment
parameters:
- name: id
description: The ID of the environment to update
type: number
required: true
- name: tagIds
description: >-
The IDs of the tags that are associated with the environment.
Must include all the tag IDs that should be associated with the environment - this includes new tags and existing tags.
Providing an empty array will remove all tags.
Example: [1, 2, 3]
type: array
required: true
items:
type: number
annotations:
title: Update Environment Tags
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentUserAccesses
description: Update the user access policies of an environment
parameters:
- name: id
description: The ID of the environment to update
type: number
required: true
- name: userAccesses
description: >-
The user accesses that are associated with the environment.
The ID is the user ID of the user in Portainer.
Must include all the access policies for all users that should be associated with the environment.
Providing an empty array will remove all user accesses.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the user
type: number
access:
description: The access level of the user
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Environment User Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentTeamAccesses
description: Update the team access policies of an environment
parameters:
- name: id
description: The ID of the environment to update
type: number
required: true
- name: teamAccesses
description: >-
The team accesses that are associated with the environment.
The ID is the team ID of the team in Portainer.
Must include all the access policies for all teams that should be associated with the environment.
Providing an empty array will remove all team accesses.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the team
type: number
access:
description: The access level of the team
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Environment Team Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Environment Groups
## An environment group is the equivalent of an Edge Group in Portainer.
## ------------------------------------------------------------
- name: createEnvironmentGroup
description: Create a new environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: name
description: The name of the environment group
type: string
required: true
- name: environmentIds
description: The IDs of the environments to add to the group
type: array
required: true
items:
type: number
annotations:
title: Create Environment Group
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: listEnvironmentGroups
description: List all available environment groups. Environment groups are the equivalent of Edge Groups in Portainer.
annotations:
title: List Environment Groups
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentGroupName
description: Update the name of an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: id
description: The ID of the environment group to update
type: number
required: true
- name: name
description: The new name for the environment group
type: string
required: true
annotations:
title: Update Environment Group Name
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentGroupEnvironments
description: Update the environments associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: id
description: The ID of the environment group to update
type: number
required: true
- name: environmentIds
description: >-
The IDs of the environments that should be part of the group.
Must include all environment IDs that should be associated with the group.
Providing an empty array will remove all environments from the group.
Example: [1, 2, 3]
type: array
required: true
items:
type: number
annotations:
title: Update Environment Group Environments
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentGroupTags
description: Update the tags associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: id
description: The ID of the environment group to update
type: number
required: true
- name: tagIds
description: >-
The IDs of the tags that should be associated with the group.
Must include all tag IDs that should be associated with the group.
Providing an empty array will remove all tags from the group.
Example: [1, 2, 3]
type: array
required: true
items:
type: number
annotations:
title: Update Environment Group Tags
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Settings
## ------------------------------------------------------------
- name: getSettings
description: Get the settings of the Portainer instance
annotations:
title: Get Settings
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Stacks
## ------------------------------------------------------------
- name: listStacks
description: List all available stacks
annotations:
title: List Stacks
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: getStackFile
description: Get the compose file for a specific stack ID
parameters:
- name: id
description: The ID of the stack to get the compose file for
type: number
required: true
annotations:
title: Get Stack File
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: createStack
description: Create a new stack
parameters:
- name: name
description: Name of the stack. Stack name must only consist of lowercase alpha
characters, numbers, hyphens, or underscores as well as start with a
lowercase character or number
type: string
required: true
- name: file
description: >-
Content of the stack file. The file must be a valid
docker-compose.yml file. example: services:
web:
image:nginx
type: string
required: true
- name: environmentGroupIds
description: "The IDs of the environment groups that the stack belongs to. Must
include at least one environment group ID. Example: [1, 2, 3]"
type: array
required: true
items:
type: number
annotations:
title: Create Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: updateStack
description: Update an existing stack
parameters:
- name: id
description: The ID of the stack to update
type: number
required: true
- name: file
description: >-
Content of the stack file. The file must be a valid
docker-compose.yml file. example: version: 3
services:
web:
image:nginx
type: string
required: true
- name: environmentGroupIds
description: "The IDs of the environment groups that the stack belongs to. Must
include at least one environment group ID. Example: [1, 2, 3]"
type: array
required: true
items:
type: number
annotations:
title: Update Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Tags
## ------------------------------------------------------------
- name: createEnvironmentTag
description: Create a new environment tag
parameters:
- name: name
description: The name of the tag
type: string
required: true
annotations:
title: Create Environment Tag
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: listEnvironmentTags
description: List all available environment tags
annotations:
title: List Environment Tags
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Teams
## ------------------------------------------------------------
- name: createTeam
description: Create a new team
parameters:
- name: name
description: The name of the team
type: string
required: true
annotations:
title: Create Team
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: listTeams
description: List all available teams
annotations:
title: List Teams
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateTeamName
description: Update the name of an existing team
parameters:
- name: id
description: The ID of the team to update
type: number
required: true
- name: name
description: The new name of the team
type: string
required: true
annotations:
title: Update Team Name
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateTeamMembers
description: Update the members of an existing team
parameters:
- name: id
description: The ID of the team to update
type: number
required: true
- name: userIds
description: "The IDs of the users that are part of the team. Must include all
the user IDs that are part of the team - this includes new users and
the existing users that are already associated with the team. Example:
[1, 2, 3]"
type: array
required: true
items:
type: number
annotations:
title: Update Team Members
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Users
## ------------------------------------------------------------
- name: listUsers
description: List all available users
annotations:
title: List Users
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateUserRole
description: Update an existing user
parameters:
- name: id
description: The ID of the user to update
type: number
required: true
- name: role
description: The role of the user. Can be admin, user or edge_admin
type: string
required: true
enum:
- admin
- user
- edge_admin
annotations:
title: Update User Role
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Docker Proxy
## ------------------------------------------------------------
- name: dockerProxy
description: Proxy Docker requests to a specific Portainer environment.
This tool can be used with any Docker API operation as documented in the Docker Engine API specification (https://docs.docker.com/reference/api/engine/version/v1.48/).
parameters:
- name: environmentId
description: The ID of the environment to proxy Docker requests to
type: number
required: true
- name: method
description: The HTTP method to use to proxy the Docker API operation
type: string
required: true
enum:
- GET
- POST
- PUT
- DELETE
- HEAD
- name: dockerAPIPath
description: "The route of the Docker API operation to proxy. Must include the leading slash. Example: /containers/json"
type: string
required: true
- name: queryParams
description: "The query parameters to include in the Docker API operation. Must be an array of key-value pairs.
Example: [{key: 'all', value: 'true'}, {key: 'filter', value: 'dangling'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the query parameter
value:
type: string
description: The value of the query parameter
- name: headers
description: "The headers to include in the Docker API operation. Must be an array of key-value pairs.
Example: [{key: 'Content-Type', value: 'application/json'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the header
value:
type: string
description: The value of the header
- name: body
description: "The body of the Docker API operation to proxy. Must be a JSON string.
Example: {'Image': 'nginx:latest', 'Name': 'my-container'}"
type: string
required: false
annotations:
title: Docker Proxy
readOnlyHint: true
destructiveHint: true
idempotentHint: true
openWorldHint: false
## Kubernetes Proxy
## ------------------------------------------------------------
- name: kubernetesProxy
description: Proxy Kubernetes requests to a specific Portainer environment.
This tool can be used with any Kubernetes API operation as documented in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
parameters:
- name: environmentId
description: The ID of the environment to proxy Kubernetes requests to
type: number
required: true
- name: method
description: The HTTP method to use to proxy the Kubernetes API operation
type: string
required: true
enum:
- GET
- POST
- PUT
- DELETE
- HEAD
- name: kubernetesAPIPath
description: "The route of the Kubernetes API operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
type: string
required: true
- name: queryParams
description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the query parameter
value:
type: string
description: The value of the query parameter
- name: headers
description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'Content-Type', value: 'application/json'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the header
value:
type: string
description: The value of the header
- name: body
description: "The body of the Kubernetes API operation to proxy. Must be a JSON string.
Example: {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'my-pod'}}"
type: string
required: false
annotations:
title: Kubernetes Proxy
readOnlyHint: true
destructiveHint: true
idempotentHint: true
openWorldHint: false
- name: getKubernetesResourceStripped
description: >-
Proxy GET requests to a specific Portainer environment for Kubernetes resources,
and automatically strips verbose metadata fields (such as 'managedFields') from the API response
to reduce its size. This tool is intended for retrieving Kubernetes resource
information where a leaner payload is desired.
This tool can be used with any GET Kubernetes API operation as documented
in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
For other methods (POST, PUT, DELETE, HEAD), use the 'kubernetesProxy' tool.
parameters:
- name: environmentId
description: The ID of the environment to proxy Kubernetes GET requests to
type: number
required: true
- name: kubernetesAPIPath
description: "The route of the Kubernetes API GET operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
type: string
required: true
- name: queryParams
description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the query parameter
value:
type: string
description: The value of the query parameter
- name: headers
description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'Accept', value: 'application/json'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the header
value:
type: string
description: The value of the header
annotations:
title: Get Kubernetes Resource (Stripped)
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
```