#
tokens: 48916/50000 79/115 files (page 1/4)
lines: off (toggle) GitHub
raw markdown copy
This is page 1 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

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
dist
.dev
.tmp
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# Portainer MCP
[![Go Report Card](https://goreportcard.com/badge/github.com/portainer/portainer-mcp)](https://goreportcard.com/report/github.com/portainer/portainer-mcp)
![coverage](https://raw.githubusercontent.com/portainer/portainer-mcp/badges/.badges/main/coverage.svg)

Ever wished you could just ask Portainer what's going on?

Now you can! Portainer MCP connects your AI assistant directly to your Portainer environments. Manage Portainer resources such as users and environments, or dive deeper by executing any Docker or Kubernetes command directly through the AI.

![portainer-mcp-demo](https://downloads.portainer.io/mcp-demo5.gif)

## Overview

Portainer MCP is a work in progress implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) for Portainer environments. This project aims to provide a standardized way to connect Portainer's container management capabilities with AI models and other services.

MCP (Model Context Protocol) is an open protocol that standardizes how applications provide context to LLMs (Large Language Models). Similar to how USB-C provides a standardized way to connect devices to peripherals, MCP provides a standardized way to connect AI models to different data sources and tools.

This implementation focuses on exposing Portainer environment data through the MCP protocol, allowing AI assistants and other tools to interact with your containerized infrastructure in a secure and standardized way.

> [!NOTE]
> This tool is designed to work with specific Portainer versions. If your Portainer version doesn't match the supported version, you can use the `--disable-version-check` flag to attempt connection anyway. See [Portainer Version Support](#portainer-version-support) for compatible versions and [Disable Version Check](#disable-version-check) for bypass instructions.

See the [Supported Capabilities](#supported-capabilities) sections for more details on compatibility and available features.

*Note: This project is currently under development.*

It is currently designed to work with a Portainer administrator API token.

## Installation

You can download pre-built binaries for Linux (amd64, arm64) and macOS (arm64) from the [**Latest Release Page**](https://github.com/portainer/portainer-mcp/releases/latest). Find the appropriate archive for your operating system and architecture under the "Assets" section.

**Download the archive:**
You can usually download this directly from the release page. Alternatively, you can use `curl`. Here's an example for macOS (ARM64) version `v0.2.0`:

```bash
# Example for macOS (ARM64) - adjust version and architecture as needed
curl -Lo portainer-mcp-v0.2.0-darwin-arm64.tar.gz https://github.com/portainer/portainer-mcp/releases/download/v0.2.0/portainer-mcp-v0.2.0-darwin-arm64.tar.gz
```

(Linux AMD64 binaries are also available on the release page.)

**(Optional but recommended) Verify the checksum:**
First, download the corresponding `.md5` checksum file from the release page.
Example for macOS (ARM64) `v0.2.0`:

```bash
# Download the checksum file (adjust version/arch)
curl -Lo portainer-mcp-v0.2.0-darwin-arm64.tar.gz.md5 https://github.com/portainer/portainer-mcp/releases/download/v0.2.0/portainer-mcp-v0.2.0-darwin-arm64.tar.gz.md5
# Now verify (output should match the content of the .md5 file)
if [ "$(md5 -q portainer-mcp-v0.2.0-darwin-arm64.tar.gz)" = "$(cat portainer-mcp-v0.2.0-darwin-arm64.tar.gz.md5)" ]; then echo "OK"; else echo "FAILED"; fi
```

(For Linux, you can use `md5sum -c <checksum_file_name>.md5`)
If the verification command outputs "OK", the file is intact.

**Extract the archive:**

```bash
# Adjust the filename based on the downloaded version/OS/architecture
tar -xzf portainer-mcp-v0.2.0-darwin-arm64.tar.gz
```

This will extract the `portainer-mcp` executable.

**Move the executable:**
Move the executable to a location in your `$PATH` (e.g., `/usr/local/bin`) or note its location for the configuration step below.

# Usage

With Claude Desktop, configure it like so:

```
{
    "mcpServers": {
        "portainer": {
            "command": "/path/to/portainer-mcp",
            "args": [
                "-server",
                "[IP]:[PORT]",
                "-token",
                "[TOKEN]",
                "-tools",
                "/tmp/tools.yaml"
            ]
        }
    }
}
```

Replace `[IP]`, `[PORT]` and `[TOKEN]` with the IP, port and API access token associated with your Portainer instance.

> [!NOTE]
> By default, the tool looks for "tools.yaml" in the same directory as the binary. If the file does not exist, it will be created there with the default tool definitions. You may need to modify this path as described above, particularly when using AI assistants like Claude that have restricted write permissions to the working directory.

## Disable Version Check

By default, the application validates that your Portainer server version matches the supported version and will fail to start if there's a mismatch. If you have a Portainer server version that doesn't have a corresponding Portainer MCP version available, you can disable this version check to attempt connection anyway.

To disable the version check, add the `-disable-version-check` flag to your command arguments:

```
{
    "mcpServers": {
        "portainer": {
            "command": "/path/to/portainer-mcp",
            "args": [
                "-server",
                "[IP]:[PORT]",
                "-token",
                "[TOKEN]",
                "-disable-version-check"
            ]
        }
    }
}
```

> [!WARNING]
> Disabling the version check may result in unexpected behavior or API incompatibilities if your Portainer server version differs significantly from the supported version. The tool may work partially or not at all with unsupported versions.

When using this flag:
- The application will skip Portainer server version validation at startup
- Some features may not work correctly due to API differences between versions
- Newer Portainer versions may have API changes that cause errors
- Older Portainer versions may be missing APIs that the tool expects

This flag is useful when:
- You're running a newer Portainer version that doesn't have MCP support yet
- You're running an older Portainer version and want to try the tool anyway

## Tool Customization

By default, the tool definitions are embedded in the binary. The application will create a tools file at the default location if one doesn't already exist.

You can customize the tool definitions by specifying a custom tools file path using the `-tools` flag:

```
{
    "mcpServers": {
        "portainer": {
            "command": "/path/to/portainer-mcp",
            "args": [
                "-server",
                "[IP]:[PORT]",
                "-token",
                "[TOKEN]",
                "-tools",
                "/path/to/custom/tools.yaml"
            ]
        }
    }
}
```

The default tools file is available for reference at `internal/tooldef/tools.yaml` in the source code. You can modify the descriptions of the tools and their parameters to alter how AI models interpret and decide to use them. You can even decide to remove some tools if you don't wish to use them.

> [!WARNING]
> Do not change the tool names or parameter definitions (other than descriptions), as this will prevent the tools from being properly registered and functioning correctly.

## Read-Only Mode

For security-conscious users, the application can be run in read-only mode. This mode ensures that only read operations are available, completely preventing any modifications to your Portainer resources.

To enable read-only mode, add the `-read-only` flag to your command arguments:

```
{
    "mcpServers": {
        "portainer": {
            "command": "/path/to/portainer-mcp",
            "args": [
                "-server",
                "[IP]:[PORT]",
                "-token",
                "[TOKEN]",
                "-read-only"
            ]
        }
    }
}
```

When using read-only mode:
- Only read tools (list, get) will be available to the AI model
- All write tools (create, update, delete) are not loaded
- The Docker proxy requests tool is not loaded
- The Kubernetes proxy requests tool is not loaded

# Portainer Version Support

This tool is pinned to support a specific version of Portainer. The application will validate the Portainer server version at startup and fail if it doesn't match the required version.

| Portainer MCP Version  | Supported Portainer Version |
|--------------|----------------------------|
| 0.1.0 | 2.28.1 |
| 0.2.0 | 2.28.1 |
| 0.3.0 | 2.28.1 |
| 0.4.0 | 2.29.2 |
| 0.4.1 | 2.29.2 |
| 0.5.0 | 2.30.0 |
| 0.6.0 | 2.31.2 |

> [!NOTE]
> If you need to connect to an unsupported Portainer version, you can use the `-disable-version-check` flag to bypass version validation. See the [Disable Version Check](#disable-version-check) section for more details and important warnings about using this feature.

# Supported Capabilities

The following table lists the currently (latest version) supported operations through MCP tools:

| Resource | Operation | Description | Supported In Version |
|----------|-----------|-------------|----------------------|
| **Environments** | | | |
| | ListEnvironments | List all available environments | 0.1.0 |
| | UpdateEnvironmentTags | Update tags associated with an environment | 0.1.0 |
| | UpdateEnvironmentUserAccesses | Update user access policies for an environment | 0.1.0 |
| | UpdateEnvironmentTeamAccesses | Update team access policies for an environment | 0.1.0 |
| **Environment Groups (Edge Groups)** | | | |
| | ListEnvironmentGroups | List all available environment groups | 0.1.0 |
| | CreateEnvironmentGroup | Create a new environment group | 0.1.0 |
| | UpdateEnvironmentGroupName | Update the name of an environment group | 0.1.0 |
| | UpdateEnvironmentGroupEnvironments | Update environments associated with a group | 0.1.0 |
| | UpdateEnvironmentGroupTags | Update tags associated with a group | 0.1.0 |
| **Access Groups (Endpoint Groups)** | | | |
| | ListAccessGroups | List all available access groups | 0.1.0 |
| | CreateAccessGroup | Create a new access group | 0.1.0 |
| | UpdateAccessGroupName | Update the name of an access group | 0.1.0 |
| | UpdateAccessGroupUserAccesses | Update user accesses for an access group | 0.1.0 |
| | UpdateAccessGroupTeamAccesses | Update team accesses for an access group | 0.1.0 |
| | AddEnvironmentToAccessGroup | Add an environment to an access group | 0.1.0 |
| | RemoveEnvironmentFromAccessGroup | Remove an environment from an access group | 0.1.0 |
| **Stacks (Edge Stacks)** | | | |
| | ListStacks | List all available stacks | 0.1.0 |
| | GetStackFile | Get the compose file for a specific stack | 0.1.0 |
| | CreateStack | Create a new Docker stack | 0.1.0 |
| | UpdateStack | Update an existing Docker stack | 0.1.0 |
| **Tags** | | | |
| | ListEnvironmentTags | List all available environment tags | 0.1.0 |
| | CreateEnvironmentTag | Create a new environment tag | 0.1.0 |
| **Teams** | | | |
| | ListTeams | List all available teams | 0.1.0 |
| | CreateTeam | Create a new team | 0.1.0 |
| | UpdateTeamName | Update the name of a team | 0.1.0 |
| | UpdateTeamMembers | Update the members of a team | 0.1.0 |
| **Users** | | | |
| | ListUsers | List all available users | 0.1.0 |
| | UpdateUser | Update an existing user | 0.1.0 |
| | GetSettings | Get the settings of the Portainer instance | 0.1.0 |
| **Docker** | | | |
| | DockerProxy | Proxy ANY Docker API requests | 0.2.0 |
| **Kubernetes** | | | |
| | KubernetesProxy | Proxy ANY Kubernetes API requests | 0.3.0 |
| | getKubernetesResourceStripped | Proxy GET Kubernetes API requests and automatically strip verbose metadata fields | 0.6.0 |

# Development

## Code Statistics

The repository includes a helper script `cloc.sh` to calculate lines of code and other metrics for the Go source files using the `cloc` tool. You might need to install `cloc` first (e.g., `sudo apt install cloc` or `brew install cloc`).

Run the script from the repository root to see the default summary output:

```bash
./cloc.sh
```

Refer to the comment header within the `cloc.sh` script for details on available flags to retrieve specific metrics.

## Token Counting

To get an estimate of how many tokens your current tool definitions consume in prompts, you can use the provided Go program and shell script to query the Anthropic API's token counting endpoint.

**1. Generate the Tools JSON:**

First, use the `token-count` Go program to convert your YAML tool definitions into the JSON format required by the Anthropic API. Run this from the repository root:

```bash
# Replace internal/tooldef/tools.yaml with your YAML file if different
# Replace .tmp/tools.json with your desired output path
go run ./cmd/token-count -input internal/tooldef/tools.yaml -output .tmp/tools.json
```

This command reads the tool definitions from the specified input YAML file and writes a JSON array of tools (containing `name`, `description`, and `input_schema`) to the specified output file.

**2. Query the Anthropic API:**

Next, use the `token.sh` script to send these tool definitions along with a sample message to the Anthropic API. You will need an Anthropic API key for this step.

```bash
# Ensure you have jq installed
# Replace sk-ant-xxxxxxxx with your actual Anthropic API key
# Replace .tmp/tools.json with the path to the file generated in step 1
./token.sh -k sk-ant-xxxxxxxx -i .tmp/tools.json
```

The script will output the JSON response from the Anthropic API, which includes the estimated token count for the provided tools and sample message under the `usage.input_tokens` field.

This process helps in understanding the token cost associated with the toolset provided to the language model.

```

--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------

```markdown
# Portainer MCP Development Guide

## Build, Test & Run Commands
- Build: `make build`
- Run tests: `go test -v ./...`
- Run single test: `go test -v ./path/to/package -run TestName`
- Lint: `go vet ./...` and `golint ./...` (install golint if needed)
- Format code: `gofmt -s -w .`
- Run inspector: `make inspector`
- Build for specific platform: `make PLATFORM=<platform> ARCH=<arch> build`
- Integration tests: `make test-integration`
- Run all tests: `make test-all`

## Code Style Guidelines
- Use standard Go naming conventions: PascalCase for exported, camelCase for private
- Follow table-driven test pattern with descriptive test cases
- Error handling: return errors with context via `fmt.Errorf("failed to X: %w", err)`
- Imports: group standard library, external packages, and internal packages
- Function comments: document exported functions with Parameters/Returns sections
- Use functional options pattern for configurable clients
- Package structure: cmd/ for entry points, internal/ for implementation, pkg/ for reusable components
- Models belong in pkg/portainer/models, client implementations in pkg/portainer/client

## Design Documentation
- Design decisions are documented in individual files in `docs/design/` directory
- Follow the naming convention: `YYMMDD-N-short-description.md` where:
  - `YYMMDD` is the date (year-month-day)
  - `N` is a sequence number for that date
  - Example: `202505-1-feature-toggles.md`
- Use the standard template structure provided in `docs/design_summary.md`
- Add new decisions to the table in `docs/design_summary.md`
- Review existing decisions before making significant architectural changes

## Client and Model Guidelines

### Client Structure
1. **Raw Client** (`portainer/client-api-go/v2`)
   - Directly communicates with Portainer API
   - Used in integration tests for ground-truth comparisons
   - Works with raw models from `github.com/portainer/client-api-go/v2/pkg/models`

2. **Wrapper Client** (`pkg/portainer/client`)
   - Abstraction layer over the Raw Client
   - Simplifies interface for the MCP application
   - Handles data transformation between Raw and Local Models
   - Used by MCP server handlers

### Model Structure
1. **Raw Models** (`portainer/client-api-go/v2/pkg/models`)
   - Direct mapping to Portainer API data structures
   - May contain fields not relevant to MCP
   - Prefix variables with `raw` (e.g., `rawSettings`, `rawEndpoint`)

2. **Local Models** (`pkg/portainer/models`)
   - Simplified structures tailored for the MCP application
   - Contain only relevant fields with convenient types
   - Define conversion functions to transform from Raw Models

### Import Conventions
```go
import (
    "github.com/portainer/portainer-mcp/pkg/portainer/models" // Default: models (Local MCP Models)
    apimodels "github.com/portainer/client-api-go/v2/pkg/models" // Alias: apimodels (Raw Client-API-Go Models)
)
```

### Testing Approach
- **Unit Tests**: Mock Raw Client interface, verify conversions and expected Local Model output
- **Integration Tests**: Call MCP handler and compare with ground-truth from Raw Client

## MCP Server Architecture

### Server Configuration
- Server is initialized in `cmd/portainer-mcp/mcp.go`
- Uses functional options pattern via `WithClient()` and `WithReadOnly()`
- Connects to Portainer API using token-based authentication
- Validates compatibility with specific Portainer version
- Loads tool definitions from YAML file

### Tool Definitions
- Tools are defined in `internal/tooldef/tools.yaml`
- File is embedded in binary at build time
- External file can override embedded definitions
- Version checking ensures compatibility
- Read-only mode restricts modification capabilities

### Handler Pattern
- Each tool has a corresponding handler in `internal/mcp/`
- Handlers follow ToolHandlerFunc signature
- Standard error handling with wrapped errors
- Parameter validation with required flag checks
- Response serialization to JSON

## Integration Testing Framework

### Test Environment Setup
- Uses Docker containers for Portainer instances
- `tests/integration/helpers/test_env.go` provides test environment utilities
- Creates isolated test environment for each test
- Configures both Raw Client and MCP Server for testing
- Automatically cleans up resources after tests

### Testing Conventions
- Tests verify both success and error conditions
- Use table-driven tests with descriptive case names
- Compare MCP handler results with direct API calls
- Validate correct error handling and parameter validation

## Version Compatibility

### Portainer Version Support
- Each release supports a specific Portainer version (defined in `server.go`)
- Version check at startup prevents compatibility issues
- Fail-fast approach with clear error messaging

### Tools File Versioning
- Strict versioning for tools.yaml file
- Version validation at startup
- Clear upgrade path for breaking changes

## Security Features

### Read-Only Mode
- Flag to enable read-only mode
- Only registers tools that don't modify resources
- Provides protection against accidental modifications
- Safe mode for monitoring and observation

### Error Handling
- Validate parameters before performing operations
- Proper error messages with context
- Fail-fast approach for invalid operations
```

--------------------------------------------------------------------------------
/internal/mcp/testdata/valid_tools.yaml:
--------------------------------------------------------------------------------

```yaml
version: v1.0
tools:
  - name: test_tool
    description: Test tool description
    parameters:
      - name: test_param
        type: string
        description: A test parameter
        required: true 
```

--------------------------------------------------------------------------------
/internal/mcp/testdata/invalid_tools.yaml:
--------------------------------------------------------------------------------

```yaml
version: "0.5"
tools:
  - name: test_tool
    description: Test tool description
    parameters:
      - name: test_param
        type: string
        description: A test parameter
        required: true

```

--------------------------------------------------------------------------------
/pkg/portainer/client/version.go:
--------------------------------------------------------------------------------

```go
package client

import "fmt"

func (c *PortainerClient) GetVersion() (string, error) {
	version, err := c.cli.GetVersion()
	if err != nil {
		return "", fmt.Errorf("failed to get version: %w", err)
	}

	return version, nil
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/settings.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
)

func (c *PortainerClient) GetSettings() (models.PortainerSettings, error) {
	settings, err := c.cli.GetSettings()
	if err != nil {
		return models.PortainerSettings{}, fmt.Errorf("failed to get settings: %w", err)
	}

	return models.ConvertSettingsToPortainerSettings(settings), nil
}

```

--------------------------------------------------------------------------------
/internal/tooldef/tooldef.go:
--------------------------------------------------------------------------------

```go
package tooldef

import (
	_ "embed"
	"os"
)

//go:embed tools.yaml
var ToolsFile []byte

// CreateToolsFileIfNotExists creates the tools.yaml file if it doesn't exist
// It returns true if the file already exists, false if it was created or an error occurred
func CreateToolsFileIfNotExists(path string) (bool, error) {
	if _, err := os.Stat(path); os.IsNotExist(err) {
		err = os.WriteFile(path, ToolsFile, 0644)
		if err != nil {
			return false, err
		}
		return false, nil
	}
	return true, nil
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/team.go:
--------------------------------------------------------------------------------

```go
package models

import (
	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
)

type Team struct {
	ID        int    `json:"id"`
	Name      string `json:"name"`
	MemberIDs []int  `json:"members"`
}

func ConvertToTeam(rawTeam *apimodels.PortainerTeam, rawMemberships []*apimodels.PortainerTeamMembership) Team {
	memberIDs := make([]int, 0)
	for _, member := range rawMemberships {
		if member.TeamID == rawTeam.ID {
			memberIDs = append(memberIDs, int(member.UserID))
		}
	}

	return Team{
		ID:        int(rawTeam.ID),
		Name:      rawTeam.Name,
		MemberIDs: memberIDs,
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/group.go:
--------------------------------------------------------------------------------

```go
package models

import (
	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
)

type Group struct {
	ID             int    `json:"id"`
	Name           string `json:"name"`
	EnvironmentIds []int  `json:"environment_ids"`
	TagIds         []int  `json:"tag_ids"`
}

func ConvertEdgeGroupToGroup(rawEdgeGroup *apimodels.EdgegroupsDecoratedEdgeGroup) Group {
	return Group{
		ID:             int(rawEdgeGroup.ID),
		Name:           rawEdgeGroup.Name,
		EnvironmentIds: utils.Int64ToIntSlice(rawEdgeGroup.Endpoints),
		TagIds:         utils.Int64ToIntSlice(rawEdgeGroup.TagIds),
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/tag.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"strconv"

	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
)

type EnvironmentTag struct {
	ID             int    `json:"id"`
	Name           string `json:"name"`
	EnvironmentIds []int  `json:"environment_ids"`
}

func ConvertTagToEnvironmentTag(rawTag *apimodels.PortainerTag) EnvironmentTag {
	environmentIDs := make([]int, 0, len(rawTag.Endpoints))

	for endpointID := range rawTag.Endpoints {
		id, err := strconv.Atoi(endpointID)
		if err == nil {
			environmentIDs = append(environmentIDs, id)
		}
	}

	return EnvironmentTag{
		ID:             int(rawTag.ID),
		Name:           rawTag.Name,
		EnvironmentIds: environmentIDs,
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/stack.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"time"

	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
)

type Stack struct {
	ID                  int    `json:"id"`
	Name                string `json:"name"`
	CreatedAt           string `json:"created_at"`
	EnvironmentGroupIds []int  `json:"group_ids"`
}

func ConvertEdgeStackToStack(rawEdgeStack *apimodels.PortainereeEdgeStack) Stack {
	createdAt := time.Unix(rawEdgeStack.CreationDate, 0).Format(time.RFC3339)

	return Stack{
		ID:                  int(rawEdgeStack.ID),
		Name:                rawEdgeStack.Name,
		CreatedAt:           createdAt,
		EnvironmentGroupIds: utils.Int64ToIntSlice(rawEdgeStack.EdgeGroups),
	}
}

```

--------------------------------------------------------------------------------
/internal/mcp/settings.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
)

func (s *PortainerMCPServer) AddSettingsFeatures() {
	s.addToolIfExists(ToolGetSettings, s.HandleGetSettings())
}

func (s *PortainerMCPServer) HandleGetSettings() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		settings, err := s.cli.GetSettings()
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get settings", err), nil
		}

		data, err := json.Marshal(settings)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to marshal settings", err), nil
		}

		return mcp.NewToolResultText(string(data)), nil
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/access_policy.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"strconv"

	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
)

func convertAccesses[T apimodels.PortainerUserAccessPolicies | apimodels.PortainerTeamAccessPolicies](rawPolicies T) map[int]string {
	accesses := make(map[int]string)
	for idStr, role := range rawPolicies {
		id, err := strconv.Atoi(idStr)
		if err == nil {
			accesses[id] = convertAccessPolicyRole(&role)
		}
	}
	return accesses
}

func convertAccessPolicyRole(rawPolicy *apimodels.PortainerAccessPolicy) string {
	switch rawPolicy.RoleID {
	case 1:
		return "environment_administrator"
	case 2:
		return "helpdesk_user"
	case 3:
		return "standard_user"
	case 4:
		return "readonly_user"
	case 5:
		return "operator_user"
	default:
		return "unknown"
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/user.go:
--------------------------------------------------------------------------------

```go
package models

import (
	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
)

type User struct {
	ID       int    `json:"id"`
	Username string `json:"username"`
	Role     string `json:"role"`
}

// User role constants
const (
	UserRoleAdmin     = "admin"
	UserRoleUser      = "user"
	UserRoleEdgeAdmin = "edge_admin"
	UserRoleUnknown   = "unknown"
)

func ConvertToUser(rawUser *apimodels.PortainereeUser) User {
	return User{
		ID:       int(rawUser.ID),
		Username: rawUser.Username,
		Role:     convertUserRole(rawUser),
	}
}

func convertUserRole(rawUser *apimodels.PortainereeUser) string {
	switch rawUser.Role {
	case 1:
		return UserRoleAdmin
	case 2:
		return UserRoleUser
	case 3:
		return UserRoleEdgeAdmin
	default:
		return UserRoleUnknown
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/docker.go:
--------------------------------------------------------------------------------

```go
package models

import "io"

// DockerProxyRequestOptions represents the options for a Docker API request to a specific Portainer environment.
type DockerProxyRequestOptions struct {
	// EnvironmentID is the ID of the environment to proxy the request to.
	EnvironmentID int
	// Method is the HTTP method to use (GET, POST, PUT, DELETE, etc.).
	Method string
	// Path is the Docker API endpoint path to proxy to (e.g., "/containers/json"). Must include the leading slash.
	Path string
	// QueryParams is a map of query parameters to include in the request URL.
	QueryParams map[string]string
	// Headers is a map of headers to include in the request.
	Headers map[string]string
	// Body is the request body to send (set it to nil for requests that don't have a body).
	Body io.Reader
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/kubernetes.go:
--------------------------------------------------------------------------------

```go
package models

import "io"

// KubernetesProxyRequestOptions represents the options for a Kubernetes API request to a specific Portainer environment.
type KubernetesProxyRequestOptions struct {
	// EnvironmentID is the ID of the environment to proxy the request to.
	EnvironmentID int
	// Method is the HTTP method to use (GET, POST, PUT, DELETE, etc.).
	Method string
	// Path is the Kubernetes API endpoint path to proxy to (e.g., "/api/v1/namespaces/default/pods"). Must include the leading slash.
	Path string
	// QueryParams is a map of query parameters to include in the request URL.
	QueryParams map[string]string
	// Headers is a map of headers to include in the request.
	Headers map[string]string
	// Body is the request body to send (set it to nil for requests that don't have a body).
	Body io.Reader
}

```

--------------------------------------------------------------------------------
/pkg/portainer/utils/utils.go:
--------------------------------------------------------------------------------

```go
package utils

// Int64ToIntSlice converts a slice of int64 values to a slice of int values.
// This may result in data loss if the int64 values exceed the range of int.
func Int64ToIntSlice(int64s []int64) []int {
	ints := make([]int, len(int64s))
	for i, int64 := range int64s {
		ints[i] = int(int64)
	}
	return ints
}

// IntToInt64Slice converts a slice of int values to a slice of int64 values.
func IntToInt64Slice(ints []int) []int64 {
	int64s := make([]int64, len(ints))
	for i, int := range ints {
		int64s[i] = int64(int)
	}
	return int64s
}

// IntToInt64Map converts a map with int keys to a map with int64 keys.
// The string values remain unchanged.
func IntToInt64Map(intMap map[int]string) map[int64]string {
	int64Map := make(map[int64]string, len(intMap))
	for key, value := range intMap {
		int64Map[int64(key)] = value
	}
	return int64Map
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/docker.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"net/http"

	"github.com/portainer/client-api-go/v2/client"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
)

// ProxyDockerRequest proxies a Docker API request to a specific Portainer environment.
//
// Parameters:
//   - opts: Options defining the proxied request (environmentID, method, path, query params, headers, body)
//
// Returns:
//   - *http.Response: The response from the Docker API
//   - error: Any error that occurred during the request
func (c *PortainerClient) ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error) {
	proxyOpts := client.ProxyRequestOptions{
		Method:  opts.Method,
		APIPath: opts.Path,
		Body:    opts.Body,
	}

	if len(opts.QueryParams) > 0 {
		proxyOpts.QueryParams = opts.QueryParams
	}

	if len(opts.Headers) > 0 {
		proxyOpts.Headers = opts.Headers
	}

	return c.cli.ProxyDockerRequest(opts.EnvironmentID, proxyOpts)
}

```

--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------

```yaml
name: Release

on:
  release:
    types: [created]

permissions:
  contents: write

jobs:
  releases-matrix:
    name: Release Go Binary
    runs-on: ubuntu-latest
    strategy:
      matrix:
        goos: [linux, darwin]
        goarch: [amd64, arm64]
        exclude:
          - goarch: "amd64"
            goos: darwin
    steps:
    - uses: actions/checkout@v4
    - id: get_version
      uses: battila7/get-version-action@v2
    - name: Set build time
      run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV}
    - uses: wangyoucao577/go-release-action@v1
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        goos: ${{ matrix.goos }}
        goarch: ${{ matrix.goarch }}
        project_path: "./cmd/portainer-mcp"
        build_flags: "-a --installsuffix cgo"
        ldflags: -s -w -X "main.Version=${{ steps.get_version.outputs.version }}" -X "main.BuildDate=${{ env.BUILD_TIME }}" -X main.Commit=${{ github.sha }}
```

--------------------------------------------------------------------------------
/pkg/portainer/models/access_group.go:
--------------------------------------------------------------------------------

```go
package models

import (
	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
)

type AccessGroup struct {
	ID             int            `json:"id"`
	Name           string         `json:"name"`
	EnvironmentIds []int          `json:"environment_ids"`
	UserAccesses   map[int]string `json:"user_accesses"`
	TeamAccesses   map[int]string `json:"team_accesses"`
}

func ConvertEndpointGroupToAccessGroup(rawGroup *apimodels.PortainerEndpointGroup, rawEndpoints []*apimodels.PortainereeEndpoint) AccessGroup {
	environmentIds := make([]int, 0)
	for _, env := range rawEndpoints {
		if env.GroupID == rawGroup.ID {
			environmentIds = append(environmentIds, int(env.ID))
		}
	}

	return AccessGroup{
		ID:             int(rawGroup.ID),
		Name:           rawGroup.Name,
		EnvironmentIds: environmentIds,
		UserAccesses:   convertAccesses(rawGroup.UserAccessPolicies),
		TeamAccesses:   convertAccesses(rawGroup.TeamAccessPolicies),
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/kubernetes.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"net/http"

	"github.com/portainer/client-api-go/v2/client"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
)

// ProxyKubernetesRequest proxies a Kubernetes API request to a specific Portainer environment.
//
// Parameters:
//   - opts: Options defining the proxied request (environmentID, method, path, query params, headers, body)
//
// Returns:
//   - *http.Response: The response from the Kubernetes API
//   - error: Any error that occurred during the request
func (c *PortainerClient) ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error) {
	proxyOpts := client.ProxyRequestOptions{
		Method:  opts.Method,
		APIPath: opts.Path,
		Body:    opts.Body,
	}

	if len(opts.QueryParams) > 0 {
		proxyOpts.QueryParams = opts.QueryParams
	}

	if len(opts.Headers) > 0 {
		proxyOpts.Headers = opts.Headers
	}

	return c.cli.ProxyKubernetesRequest(opts.EnvironmentID, proxyOpts)
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/version_test.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestGetVersion(t *testing.T) {
	tests := []struct {
		name           string
		mockVersion    string
		mockError      error
		expectedResult string
		expectedError  bool
	}{
		{
			name:           "successful retrieval",
			mockVersion:    "2.19.0",
			mockError:      nil,
			expectedResult: "2.19.0",
			expectedError:  false,
		},
		{
			name:           "api error",
			mockVersion:    "",
			mockError:      fmt.Errorf("api error"),
			expectedResult: "",
			expectedError:  true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			mockAPI.On("GetVersion").Return(tt.mockVersion, tt.mockError)

			client := &PortainerClient{
				cli: mockAPI,
			}

			version, err := client.GetVersion()

			if tt.expectedError {
				assert.Error(t, err)
				assert.Equal(t, "", version)
			} else {
				assert.NoError(t, err)
				assert.Equal(t, tt.expectedResult, version)
			}

			mockAPI.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/settings.go:
--------------------------------------------------------------------------------

```go
package models

import apimodels "github.com/portainer/client-api-go/v2/pkg/models"

type PortainerSettings struct {
	Authentication struct {
		Method string `json:"method"`
	} `json:"authentication"`
	Edge struct {
		Enabled   bool   `json:"enabled"`
		ServerURL string `json:"server_url"`
	} `json:"edge"`
}

const (
	AuthenticationMethodInternal = "internal"
	AuthenticationMethodLDAP     = "ldap"
	AuthenticationMethodOAuth    = "oauth"
	AuthenticationMethodUnknown  = "unknown"
)

func ConvertSettingsToPortainerSettings(rawSettings *apimodels.PortainereeSettings) PortainerSettings {
	s := PortainerSettings{}

	s.Authentication.Method = convertAuthenticationMethod(rawSettings.AuthenticationMethod)
	s.Edge.Enabled = rawSettings.EnableEdgeComputeFeatures
	s.Edge.ServerURL = rawSettings.Edge.TunnelServerAddress

	return s
}

func convertAuthenticationMethod(method int64) string {
	switch method {
	case 1:
		return AuthenticationMethodInternal
	case 2:
		return AuthenticationMethodLDAP
	case 3:
		return AuthenticationMethodOAuth
	default:
		return AuthenticationMethodUnknown
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/tag.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
)

// GetEnvironmentTags retrieves all environment tags from the Portainer server.
// Environment tags are the equivalent of Tags in Portainer.
//
// Returns:
//   - A slice of EnvironmentTag objects
//   - An error if the operation fails
func (c *PortainerClient) GetEnvironmentTags() ([]models.EnvironmentTag, error) {
	tags, err := c.cli.ListTags()
	if err != nil {
		return nil, fmt.Errorf("failed to list environment tags: %w", err)
	}

	environmentTags := make([]models.EnvironmentTag, len(tags))
	for i, tag := range tags {
		environmentTags[i] = models.ConvertTagToEnvironmentTag(tag)
	}

	return environmentTags, nil
}

// CreateEnvironmentTag creates a new environment tag on the Portainer server.
// Environment tags are the equivalent of Tags in Portainer.
//
// Parameters:
//   - name: The name of the environment tag
//
// Returns:
//   - The ID of the created environment tag
//   - An error if the operation fails
func (c *PortainerClient) CreateEnvironmentTag(name string) (int, error) {
	id, err := c.cli.CreateTag(name)
	if err != nil {
		return 0, fmt.Errorf("failed to create environment tag: %w", err)
	}

	return int(id), nil
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/user.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
)

// GetUsers retrieves all users from the Portainer server.
//
// Returns:
//   - A slice of User objects containing user information
//   - An error if the operation fails
func (c *PortainerClient) GetUsers() ([]models.User, error) {
	portainerUsers, err := c.cli.ListUsers()
	if err != nil {
		return nil, fmt.Errorf("failed to list users: %w", err)
	}

	users := make([]models.User, len(portainerUsers))
	for i, user := range portainerUsers {
		users[i] = models.ConvertToUser(user)
	}

	return users, nil
}

// UpdateUserRole updates the role of a user.
//
// Parameters:
//   - id: The ID of the user to update
//   - role: The new role for the user. Must be one of: admin, user, edge_admin
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateUserRole(id int, role string) error {
	roleInt := convertRole(role)
	if roleInt == 0 {
		return fmt.Errorf("invalid role: must be admin, user or edge_admin")
	}

	return c.cli.UpdateUserRole(id, roleInt)
}

func convertRole(role string) int64 {
	switch role {
	case models.UserRoleAdmin:
		return 1
	case models.UserRoleUser:
		return 2
	case models.UserRoleEdgeAdmin:
		return 3
	default:
		return 0
	}
}

```

--------------------------------------------------------------------------------
/internal/mcp/schema_test.go:
--------------------------------------------------------------------------------

```go
package mcp

import "testing"

func TestIsValidAccessLevel(t *testing.T) {
	tests := []struct {
		name        string
		accessLevel string
		want        bool
	}{
		{"ValidEnvironmentAdmin", AccessLevelEnvironmentAdmin, true},
		{"ValidHelpdeskUser", AccessLevelHelpdeskUser, true},
		{"ValidStandardUser", AccessLevelStandardUser, true},
		{"ValidReadonlyUser", AccessLevelReadonlyUser, true},
		{"ValidOperatorUser", AccessLevelOperatorUser, true},
		{"InvalidEmpty", "", false},
		{"InvalidRandom", "invalid_access", false},
		{"CaseSensitive", "ENVIRONMENT_ADMINISTRATOR", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := isValidAccessLevel(tt.accessLevel); got != tt.want {
				t.Errorf("isValidAccessLevel(%q) = %v, want %v", tt.accessLevel, got, tt.want)
			}
		})
	}
}

func TestIsValidUserRole(t *testing.T) {
	tests := []struct {
		name     string
		userRole string
		want     bool
	}{
		{"ValidAdmin", UserRoleAdmin, true},
		{"ValidUser", UserRoleUser, true},
		{"ValidEdgeAdmin", UserRoleEdgeAdmin, true},
		{"InvalidEmpty", "", false},
		{"InvalidRandom", "invalid_role", false},
		{"CaseSensitive", "ADMIN", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := isValidUserRole(tt.userRole); got != tt.want {
				t.Errorf("isValidUserRole(%q) = %v, want %v", tt.userRole, got, tt.want)
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/client_test.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestNewPortainerClient(t *testing.T) {
	tests := []struct {
		name        string
		serverURL   string
		token       string
		opts        []ClientOption
		expectError bool
	}{
		{
			name:      "creates client with default options",
			serverURL: "https://portainer.example.com",
			token:     "test-token",
			opts:      nil,
		},
		{
			name:      "creates client with skip TLS verify",
			serverURL: "https://portainer.example.com",
			token:     "test-token",
			opts:      []ClientOption{WithSkipTLSVerify(true)},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Create client
			c := NewPortainerClient(tt.serverURL, tt.token, tt.opts...)

			// Assert client was created
			assert.NotNil(t, c)
			assert.NotNil(t, c.cli)
		})
	}
}

func TestWithSkipTLSVerify(t *testing.T) {
	tests := []struct {
		name     string
		skip     bool
		expected bool
	}{
		{
			name:     "enables TLS verification skip",
			skip:     true,
			expected: true,
		},
		{
			name:     "disables TLS verification skip",
			skip:     false,
			expected: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Create options
			options := &clientOptions{}
			opt := WithSkipTLSVerify(tt.skip)
			opt(options)

			// Assert option was applied correctly
			assert.Equal(t, tt.expected, options.skipTLSVerify)
		})
	}
}

```

--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------

```yaml
name: CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
    types: [opened, reopened, synchronize]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: "checkout the current branch"
        uses: actions/checkout@v4
      - name: "set up golang"
        uses: actions/[email protected]
        with:
          go-version-file: go.mod
          cache-dependency-path: go.sum
      - name: "Build the binary"
        run: make build
      - name: "Run unit tests"
        run: make test-coverage
      - name: "Run integration tests"
        run: make test-integration
      - name: "check test coverage"
        uses: vladopajic/go-test-coverage@v2
        with:
          profile: coverage.out
          git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }}
          git-branch: badges
      - name: "Archive code coverage results"
        uses: actions/upload-artifact@v4
        with:
          name: code-coverage
          path: coverage.out

  code_coverage:
    name: "Code coverage report"
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'pull_request'
    permissions:
      contents: read
      actions: read
      pull-requests: write
    steps:
      - name: "checkout the current branch"
        uses: actions/checkout@v4
      - uses: fgrosse/[email protected]
        with:
          coverage-file-name: "coverage.out"

```

--------------------------------------------------------------------------------
/tests/integration/helpers/test_env.go:
--------------------------------------------------------------------------------

```go
package helpers

import (
	"context"
	"fmt"
	"testing"

	"github.com/portainer/client-api-go/v2/client"
	"github.com/portainer/portainer-mcp/internal/mcp"
	"github.com/portainer/portainer-mcp/tests/integration/containers"
	"github.com/stretchr/testify/require"
)

const (
	ToolsPath = "../../internal/tooldef/tools.yaml"
)

// TestEnv holds the test environment configuration and clients
type TestEnv struct {
	Ctx       context.Context
	Portainer *containers.PortainerContainer
	RawClient *client.PortainerClient
	MCPServer *mcp.PortainerMCPServer
}

// NewTestEnv creates a new test environment with Portainer container and clients
func NewTestEnv(t *testing.T, opts ...containers.PortainerContainerOption) *TestEnv {
	ctx := context.Background()

	portainer, err := containers.NewPortainerContainer(ctx, opts...)
	require.NoError(t, err, "Failed to start Portainer container")

	host, port := portainer.GetHostAndPort()
	serverURL := fmt.Sprintf("%s:%s", host, port)

	rawCli := client.NewPortainerClient(
		serverURL,
		portainer.GetAPIToken(),
		client.WithSkipTLSVerify(true),
	)

	mcpServer, err := mcp.NewPortainerMCPServer(serverURL, portainer.GetAPIToken(), ToolsPath)
	require.NoError(t, err, "Failed to create MCP server")

	return &TestEnv{
		Ctx:       ctx,
		Portainer: portainer,
		RawClient: rawCli,
		MCPServer: mcpServer,
	}
}

// Cleanup terminates the Portainer container
func (e *TestEnv) Cleanup(t *testing.T) {
	if err := e.Portainer.Terminate(e.Ctx); err != nil {
		t.Logf("Failed to terminate container: %v", err)
	}
}

```

--------------------------------------------------------------------------------
/internal/mcp/tag.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/toolgen"
)

func (s *PortainerMCPServer) AddTagFeatures() {
	s.addToolIfExists(ToolListEnvironmentTags, s.HandleGetEnvironmentTags())

	if !s.readOnly {
		s.addToolIfExists(ToolCreateEnvironmentTag, s.HandleCreateEnvironmentTag())
	}
}

func (s *PortainerMCPServer) HandleGetEnvironmentTags() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		environmentTags, err := s.cli.GetEnvironmentTags()
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get environment tags", err), nil
		}

		data, err := json.Marshal(environmentTags)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to marshal environment tags", err), nil
		}

		return mcp.NewToolResultText(string(data)), nil
	}
}

func (s *PortainerMCPServer) HandleCreateEnvironmentTag() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		name, err := parser.GetString("name", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
		}

		id, err := s.cli.CreateEnvironmentTag(name)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to create environment tag", err), nil
		}

		return mcp.NewToolResultText(fmt.Sprintf("Environment tag created successfully with ID: %d", id)), nil
	}
}

```

--------------------------------------------------------------------------------
/internal/mcp/user.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/toolgen"
)

func (s *PortainerMCPServer) AddUserFeatures() {
	s.addToolIfExists(ToolListUsers, s.HandleGetUsers())

	if !s.readOnly {
		s.addToolIfExists(ToolUpdateUserRole, s.HandleUpdateUserRole())
	}
}

func (s *PortainerMCPServer) HandleGetUsers() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		users, err := s.cli.GetUsers()
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get users", err), nil
		}

		data, err := json.Marshal(users)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to marshal users", err), nil
		}

		return mcp.NewToolResultText(string(data)), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateUserRole() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		role, err := parser.GetString("role", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid role parameter", err), nil
		}

		if !isValidUserRole(role) {
			return mcp.NewToolResultError(fmt.Sprintf("invalid role %s: must be one of: %v", role, AllUserRoles)), nil
		}

		err = s.cli.UpdateUserRole(id, role)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update user role", err), nil
		}

		return mcp.NewToolResultText("User updated successfully"), nil
	}
}

```

--------------------------------------------------------------------------------
/docs/design/202504-1-embedded-tools-yaml.md:
--------------------------------------------------------------------------------

```markdown
# 202504-1: Embedding tools.yaml in the binary

**Date**: 08/04/2025

### Context
After deciding to use an external tools.yaml file for tool definitions (see 202503-1), there was a need to determine the best distribution method for this file. Questions arose about how to ensure the file is available when the application runs.

### Decision
Embed the tools.yaml file directly in the binary during the build process, while also checking for and using a user-provided version at runtime if available.

### Rationale
1. **Simplified Distribution**
   - Single binary contains everything needed to run the application
   - No need to manage separate file distribution
   - Eliminates file path configuration issues

2. **User Customization**
   - Application checks for external tools.yaml at startup
   - If found, uses the external file for tool definitions
   - If not found, creates it using the embedded version as reference

3. **Default Configuration**
   - Provides sensible defaults out of the box
   - Ensures application can always run even without external configuration
   - Serves as a reference for users who want to customize

4. **Version Control**
   - Embedded file serves as the official version for each release
   - External file allows for hotfixes without binary updates
   - Clear separation between default and custom configurations

### Trade-offs

**Benefits**
- Simpler distribution process
- Self-contained application
- Ability to run without configuration
- Support for user customization
- Clear fallback mechanism

**Challenges**
- Slightly larger binary size
- Need for embedding logic in the build process
- Managing differences between embedded and external versions
- Ensuring proper precedence between versions
```

--------------------------------------------------------------------------------
/internal/mcp/utils.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"fmt"
	"slices"

	"github.com/mark3labs/mcp-go/mcp"
)

// parseAccessMap parses access entries from an array of objects and returns a map of ID to access level
func parseAccessMap(entries []any) (map[int]string, error) {
	accessMap := map[int]string{}

	for _, entry := range entries {
		entryMap, ok := entry.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("invalid access entry: %v", entry)
		}

		id, ok := entryMap["id"].(float64)
		if !ok {
			return nil, fmt.Errorf("invalid ID: %v", entryMap["id"])
		}

		access, ok := entryMap["access"].(string)
		if !ok {
			return nil, fmt.Errorf("invalid access: %v", entryMap["access"])
		}

		if !isValidAccessLevel(access) {
			return nil, fmt.Errorf("invalid access level: %s", access)
		}

		accessMap[int(id)] = access
	}

	return accessMap, nil
}

// parseKeyValueMap parses a slice of map[string]any into a map[string]string,
// expecting each map to have "key" and "value" string fields.
func parseKeyValueMap(items []any) (map[string]string, error) {
	resultMap := map[string]string{}

	for _, item := range items {
		itemMap, ok := item.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("invalid item: %v", item)
		}

		key, ok := itemMap["key"].(string)
		if !ok {
			return nil, fmt.Errorf("invalid key: %v", itemMap["key"])
		}

		value, ok := itemMap["value"].(string)
		if !ok {
			return nil, fmt.Errorf("invalid value: %v", itemMap["value"])
		}

		resultMap[key] = value
	}

	return resultMap, nil
}

func isValidHTTPMethod(method string) bool {
	validMethods := []string{"GET", "POST", "PUT", "DELETE", "HEAD"}
	return slices.Contains(validMethods, method)
}

// CreateMCPRequest creates a new MCP tool request with the given arguments
func CreateMCPRequest(args map[string]any) mcp.CallToolRequest {
	return mcp.CallToolRequest{
		Params: mcp.CallToolParams{
			Arguments: args,
		},
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/group_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"reflect"
	"testing"

	"github.com/portainer/client-api-go/v2/pkg/models"
)

func TestConvertEdgeGroupToGroup(t *testing.T) {
	tests := []struct {
		name      string
		edgeGroup *models.EdgegroupsDecoratedEdgeGroup
		want      Group
	}{
		{
			name: "basic edge group conversion",
			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
				ID:        1,
				Name:      "Production Servers",
				Endpoints: []int64{1, 2, 3},
				TagIds:    []int64{1, 2},
			},
			want: Group{
				ID:             1,
				Name:           "Production Servers",
				EnvironmentIds: []int{1, 2, 3},
				TagIds:         []int{1, 2},
			},
		},
		{
			name: "edge group with no endpoints",
			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
				ID:        2,
				Name:      "Empty Group",
				Endpoints: []int64{},
				TagIds:    []int64{},
			},
			want: Group{
				ID:             2,
				Name:           "Empty Group",
				EnvironmentIds: []int{},
				TagIds:         []int{},
			},
		},
		{
			name: "edge group with single endpoint",
			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
				ID:        3,
				Name:      "Single Server",
				Endpoints: []int64{4},
			},
			want: Group{
				ID:             3,
				Name:           "Single Server",
				EnvironmentIds: []int{4},
				TagIds:         []int{},
			},
		},
		{
			name: "edge group with no tags",
			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
				ID:        4,
				Name:      "No Tags Group",
				Endpoints: []int64{5},
				TagIds:    []int64{},
			},
			want: Group{
				ID:             4,
				Name:           "No Tags Group",
				EnvironmentIds: []int{5},
				TagIds:         []int{},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := ConvertEdgeGroupToGroup(tt.edgeGroup)
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("ConvertEdgeGroupToGroup() = %v, want %v", got, tt.want)
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/cmd/token-count/token.go:
--------------------------------------------------------------------------------

```go
package main

import (
	"encoding/json"
	"flag"
	"os"

	"github.com/portainer/portainer-mcp/pkg/toolgen"
	"github.com/rs/zerolog/log"
)

// AnthropicTool defines the structure expected by the Anthropic API
type AnthropicTool struct {
	Name        string `json:"name"`
	Description string `json:"description"`
	InputSchema any    `json:"input_schema"`
	// Annotations any    `json:"annotations"` // Annotations are currently not supported by the Anthropic API
}

func main() {
	inputYamlPath := flag.String("input", "", "Path to the input tools YAML file (mandatory)")
	outputPath := flag.String("output", "", "Path to the output JSON file (mandatory)")
	flag.Parse()

	if *inputYamlPath == "" {
		log.Fatal().Msg("Input YAML path is mandatory. Please specify using -input flag.")
	}
	if *outputPath == "" {
		log.Fatal().Msg("Output path is mandatory. Please specify using -output flag.")
	}

	tools, err := toolgen.LoadToolsFromYAML(*inputYamlPath, "1.0")
	if err != nil {
		log.Fatal().Err(err).Msg("failed to load tools")
	}

	// Convert map[string]mcp.Tool to []AnthropicTool for correct JSON structure
	var anthropicToolList []AnthropicTool
	for _, tool := range tools {
		// Only include fields expected by Anthropic
		anthropicTool := AnthropicTool{
			Name:        tool.Name,
			Description: tool.Description,
			InputSchema: tool.InputSchema, // Assuming mcp.Tool has InputSchema field
			// Annotations: tool.Annotations, // Removed annotations
		}
		anthropicToolList = append(anthropicToolList, anthropicTool)
	}

	jsonData, err := json.MarshalIndent(anthropicToolList, "", "  ")
	if err != nil {
		log.Fatal().Err(err).Msg("failed to marshal tools to JSON")
	}

	err = os.WriteFile(*outputPath, jsonData, 0644)
	if err != nil {
		log.Fatal().Err(err).Str("path", *outputPath).Msg("failed to write JSON to file")
	}

	log.Info().Str("path", *outputPath).Msg("Successfully wrote tools to JSON file")
}

```

--------------------------------------------------------------------------------
/docs/design/202503-1-external-tools-file.md:
--------------------------------------------------------------------------------

```markdown
# 202503-1: Using an external tools file for tool definition

**Date**: 29/03/2025

### Context
The project needs to define and maintain a set of tools that interact with Portainer. Initially, these tool definitions could have been hardcoded within the application code.

### Decision
Tool definitions are externalized into a separate `tools.yaml` file instead of maintaining them in the source code.

### Rationale
1. **Improved Readability**
   - Tool definitions often contain multi-line descriptions and complex parameter structures
   - YAML format provides better readability and structure compared to in-code definitions
   - Separates concerns: tool definitions from implementation logic

2. **Dynamic Updates**
   - Allows modification of tool descriptions and parameters without rebuilding the binary
   - Enables rapid iteration on tool definitions
   - Particularly valuable when experimenting with LLM interactions, as descriptions can be optimized for AI comprehension without code changes

3. **Maintenance Benefits**
   - Single source of truth for tool definitions
   - Easier to review and validate changes to tool definitions
   - Simplified version control for documentation changes

4. **Version Management**
   - External file format may need versioning as schema evolves
   - Requires consideration of backward compatibility
   - Enables tracking of breaking changes in tool definitions

### Trade-offs

**Benefits**
- More flexible maintenance of tool definitions
- Better separation of concerns
- Easier experimentation with LLM-optimized descriptions
- Independent evolution of tool definitions and code
- Improved visibility and security through externalized tool definitions, making it easier for users to audit and understand potential prompt injection risks

**Challenges**
- Need to handle file loading and validation
- Must ensure file distribution with the binary
- Additional complexity in version management
```

--------------------------------------------------------------------------------
/pkg/portainer/models/user_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"testing"

	"github.com/portainer/client-api-go/v2/pkg/models"
)

func TestConvertToUser(t *testing.T) {
	tests := []struct {
		name     string
		input    *models.PortainereeUser
		expected User
	}{
		{
			name: "admin user",
			input: &models.PortainereeUser{
				ID:       1,
				Username: "admin",
				Role:     1,
			},
			expected: User{
				ID:       1,
				Username: "admin",
				Role:     UserRoleAdmin,
			},
		},
		{
			name: "regular user",
			input: &models.PortainereeUser{
				ID:       2,
				Username: "user1",
				Role:     2,
			},
			expected: User{
				ID:       2,
				Username: "user1",
				Role:     UserRoleUser,
			},
		},
		{
			name: "edge admin user",
			input: &models.PortainereeUser{
				ID:       3,
				Username: "edge_admin",
				Role:     3,
			},
			expected: User{
				ID:       3,
				Username: "edge_admin",
				Role:     UserRoleEdgeAdmin,
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := ConvertToUser(tt.input)
			if result != tt.expected {
				t.Errorf("ConvertToUser() = %v, want %v", result, tt.expected)
			}
		})
	}
}

func TestConvertUserRole(t *testing.T) {
	tests := []struct {
		name     string
		input    *models.PortainereeUser
		expected string
	}{
		{
			name:     "admin role",
			input:    &models.PortainereeUser{Role: 1},
			expected: UserRoleAdmin,
		},
		{
			name:     "user role",
			input:    &models.PortainereeUser{Role: 2},
			expected: UserRoleUser,
		},
		{
			name:     "edge admin role",
			input:    &models.PortainereeUser{Role: 3},
			expected: UserRoleEdgeAdmin,
		},
		{
			name:     "unknown role",
			input:    &models.PortainereeUser{Role: 999},
			expected: UserRoleUnknown,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := convertUserRole(tt.input)
			if result != tt.expected {
				t.Errorf("convertUserRole() = %v, want %v", result, tt.expected)
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/docs/design/202504-2-tools-yaml-versioning.md:
--------------------------------------------------------------------------------

```markdown
# 202504-2: Strict versioning for tools.yaml file

**Date**: 08/04/2025

### Context
With tools.yaml being externalized and allowing user customization, there's a risk of incompatibility between the tool definitions and the application code. Changes to the schema or expected tool definitions could lead to runtime errors that are difficult to diagnose.

### Decision
Implement strict versioning for the tools.yaml file with version validation at startup. The application will define a required/current version, check if the provided tools.yaml file uses this version, and fail fast if there's a version mismatch.

### Rationale
1. **Compatibility Assurance**
   - Prevents runtime errors caused by incompatible tool definitions
   - Clearly communicates version requirements to users
   - Makes version mismatches immediately apparent

2. **Error Handling**
   - Provides clear error messages about version mismatches
   - Fails fast instead of letting subtle errors occur during operation
   - Guides users toward proper resolution

3. **Recovery Path**
   - Users can update their tools.yaml file manually to match the required version
   - Alternatively, users can simply delete their customized file and let the application regenerate it
   - Regeneration uses the embedded version which is guaranteed to be compatible

4. **Upgrade Management**
   - Clear versioning creates explicit upgrade paths
   - Version checks provide a mechanism to enforce schema migrations
   - Makes breaking changes in tool definitions more manageable

### Trade-offs

**Benefits**
- Prevents subtle runtime errors
- Provides clear error messages
- Offers straightforward recovery options
- Makes version incompatibilities immediately apparent
- Simplifies upgrade paths

**Challenges**
- Need to manage version numbers across releases
- Must communicate version changes to users
- Requires additional validation logic at startup
- Necessitates documentation of version compatibility
```

--------------------------------------------------------------------------------
/pkg/portainer/models/access_group_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"reflect"
	"testing"

	"github.com/portainer/client-api-go/v2/pkg/models"
)

func TestConvertEndpointGroupToAccessGroup(t *testing.T) {
	tests := []struct {
		name     string
		group    *models.PortainerEndpointGroup
		envs     []*models.PortainereeEndpoint
		expected AccessGroup
	}{
		{
			name: "group with multiple environments and accesses",
			group: &models.PortainerEndpointGroup{
				ID:   1,
				Name: "Production",
				UserAccessPolicies: map[string]models.PortainerAccessPolicy{
					"1": {RoleID: 1},
					"2": {RoleID: 2},
				},
				TeamAccessPolicies: map[string]models.PortainerAccessPolicy{
					"10": {RoleID: 3},
					"20": {RoleID: 4},
				},
			},
			envs: []*models.PortainereeEndpoint{
				{ID: 100, GroupID: 1},
				{ID: 101, GroupID: 1},
				{ID: 102, GroupID: 2}, // Different group
			},
			expected: AccessGroup{
				ID:             1,
				Name:           "Production",
				EnvironmentIds: []int{100, 101},
				UserAccesses: map[int]string{
					1: "environment_administrator",
					2: "helpdesk_user",
				},
				TeamAccesses: map[int]string{
					10: "standard_user",
					20: "readonly_user",
				},
			},
		},
		{
			name: "group with no environments",
			group: &models.PortainerEndpointGroup{
				ID:   2,
				Name: "Empty",
				UserAccessPolicies: map[string]models.PortainerAccessPolicy{
					"1": {RoleID: 5},
				},
				TeamAccessPolicies: map[string]models.PortainerAccessPolicy{},
			},
			envs: []*models.PortainereeEndpoint{
				{ID: 100, GroupID: 1}, // Different group
			},
			expected: AccessGroup{
				ID:             2,
				Name:           "Empty",
				EnvironmentIds: []int{},
				UserAccesses: map[int]string{
					1: "operator_user",
				},
				TeamAccesses: map[int]string{},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := ConvertEndpointGroupToAccessGroup(tt.group, tt.envs)

			if !reflect.DeepEqual(result, tt.expected) {
				t.Errorf("ConvertEndpointGroupToAccessGroup() = %v, want %v", result, tt.expected)
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/tests/integration/settings_test.go:
--------------------------------------------------------------------------------

```go
package integration

import (
	"encoding/json"
	"testing"

	go_mcp "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"
)

// TestSettingsManagement is an integration test suite that verifies the retrieval
// of Portainer settings via the MCP handler.
func TestSettingsManagement(t *testing.T) {
	env := helpers.NewTestEnv(t)
	defer env.Cleanup(t)

	// Subtest: Settings Retrieval
	// Verifies that:
	// - Settings can be correctly retrieved from the system via the MCP handler.
	// - The retrieved settings match the expected values after preparation.
	t.Run("Settings Retrieval", func(t *testing.T) {
		handler := env.MCPServer.HandleGetSettings()
		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
		require.NoError(t, err, "Failed to get settings via MCP handler")

		assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
		textContent, ok := result.Content[0].(go_mcp.TextContent)
		assert.True(t, ok, "Expected text content in response")

		// Unmarshal the result from the MCP handler into the local models.PortainerSettings struct
		var retrievedSettings models.PortainerSettings
		err = json.Unmarshal([]byte(textContent.Text), &retrievedSettings)
		require.NoError(t, err, "Failed to unmarshal retrieved settings")

		// Fetch settings directly via client to compare
		rawSettings, err := env.RawClient.GetSettings()
		require.NoError(t, err, "Failed to get settings directly via client for comparison")

		// Convert the raw settings using the package's conversion function
		expectedConvertedSettings := models.ConvertSettingsToPortainerSettings(rawSettings)

		// Compare the Settings struct from MCP handler with the one converted from the direct client call
		assert.Equal(t, expectedConvertedSettings, retrievedSettings, "Mismatch between MCP handler settings and converted client settings")
	})
}

```

--------------------------------------------------------------------------------
/docs/design/202503-2-tools-vs-mcp-resources.md:
--------------------------------------------------------------------------------

```markdown
# 202503-2: Using tools to get resources instead of MCP resources

**Date**: 29/03/2025

### Context
Initially, listing Portainer resources (environments, environment groups, stacks, etc.) was implemented using MCP resources. The project needed to evaluate whether this was the optimal approach given the current usage patterns and client constraints.

### Decision
Replace MCP resources with tools for retrieving Portainer resources. For example, instead of exposing environments as MCP resources, provide a `listEnvironments` tool that the model can invoke.

### Rationale
1. **Client Compatibility**
   - Project currently relies on existing MCP clients (e.g., Claude Desktop)
   - MCP resources require manual selection in these clients
   - One-by-one resource selection creates friction in testing and iteration

2. **Protocol Design Alignment**
   - MCP resources are designed to be application-driven, requiring UI elements for selection
   - Tools are designed to be model-controlled, better matching current use case
   - Better alignment with the protocol's intended interaction patterns

3. **User Experience**
   - Models can directly request resource listings using natural language
   - No need for manual resource selection in the client
   - Faster iteration and testing cycles

4. **Model Control**
   - Tools provide a more direct interaction model for AI
   - Models can determine when and what resources to list
   - Approval flow is streamlined through tool invocation

### Trade-offs

**Benefits**
- Improved user experience through natural language requests
- Faster testing and iteration cycles
- Better alignment with existing client capabilities
- More direct model control over resource access

**Challenges**
- Potential loss of MCP resource-specific features
- May need to reconsider if application-driven selection becomes necessary or when we'll need to build our own client

### References
- https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#user-interaction-model
- https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#user-interaction-model
```

--------------------------------------------------------------------------------
/pkg/portainer/models/access_policy_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"reflect"
	"testing"

	"github.com/portainer/client-api-go/v2/pkg/models"
)

func TestConvertAccessPolicyRole(t *testing.T) {
	tests := []struct {
		name     string
		role     *models.PortainerAccessPolicy
		expected string
	}{
		{
			name:     "environment administrator role",
			role:     &models.PortainerAccessPolicy{RoleID: 1},
			expected: "environment_administrator",
		},
		{
			name:     "helpdesk user role",
			role:     &models.PortainerAccessPolicy{RoleID: 2},
			expected: "helpdesk_user",
		},
		{
			name:     "standard user role",
			role:     &models.PortainerAccessPolicy{RoleID: 3},
			expected: "standard_user",
		},
		{
			name:     "readonly user role",
			role:     &models.PortainerAccessPolicy{RoleID: 4},
			expected: "readonly_user",
		},
		{
			name:     "operator user role",
			role:     &models.PortainerAccessPolicy{RoleID: 5},
			expected: "operator_user",
		},
		{
			name:     "unknown role",
			role:     &models.PortainerAccessPolicy{RoleID: 999},
			expected: "unknown",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := convertAccessPolicyRole(tt.role)
			if result != tt.expected {
				t.Errorf("convertAccessPolicyRole() = %v, want %v", result, tt.expected)
			}
		})
	}
}

func TestConvertAccesses(t *testing.T) {
	t.Run("user accesses", func(t *testing.T) {
		policies := models.PortainerUserAccessPolicies{
			"1": models.PortainerAccessPolicy{RoleID: 1},
			"2": models.PortainerAccessPolicy{RoleID: 3},
		}
		expected := map[int]string{
			1: "environment_administrator",
			2: "standard_user",
		}
		result := convertAccesses(policies)
		if !reflect.DeepEqual(result, expected) {
			t.Errorf("convertAccesses() = %v, want %v", result, expected)
		}
	})

	t.Run("team accesses", func(t *testing.T) {
		policies := models.PortainerTeamAccessPolicies{
			"10": models.PortainerAccessPolicy{RoleID: 1},
			"20": models.PortainerAccessPolicy{RoleID: 4},
		}
		expected := map[int]string{
			10: "environment_administrator",
			20: "readonly_user",
		}
		result := convertAccesses(policies)
		if !reflect.DeepEqual(result, expected) {
			t.Errorf("convertAccesses() = %v, want %v", result, expected)
		}
	})
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/team_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"reflect"
	"testing"

	"github.com/portainer/client-api-go/v2/pkg/models"
)

func TestConvertToTeam(t *testing.T) {
	tests := []struct {
		name         string
		team         *models.PortainerTeam
		memberships  []*models.PortainerTeamMembership
		expectedTeam Team
	}{
		{
			name: "team with multiple members",
			team: &models.PortainerTeam{
				ID:   1,
				Name: "DevOps",
			},
			memberships: []*models.PortainerTeamMembership{
				{TeamID: 1, UserID: 100},
				{TeamID: 1, UserID: 101},
				{TeamID: 1, UserID: 102},
				{TeamID: 2, UserID: 200}, // Different team, should be ignored
			},
			expectedTeam: Team{
				ID:        1,
				Name:      "DevOps",
				MemberIDs: []int{100, 101, 102},
			},
		},
		{
			name: "team with no members",
			team: &models.PortainerTeam{
				ID:   2,
				Name: "Empty Team",
			},
			memberships: []*models.PortainerTeamMembership{
				{TeamID: 1, UserID: 100}, // Different team
				{TeamID: 3, UserID: 300}, // Different team
			},
			expectedTeam: Team{
				ID:        2,
				Name:      "Empty Team",
				MemberIDs: []int{},
			},
		},
		{
			name: "team with single member",
			team: &models.PortainerTeam{
				ID:   3,
				Name: "Solo Team",
			},
			memberships: []*models.PortainerTeamMembership{
				{TeamID: 3, UserID: 300},
			},
			expectedTeam: Team{
				ID:        3,
				Name:      "Solo Team",
				MemberIDs: []int{300},
			},
		},
		{
			name: "team with empty memberships list",
			team: &models.PortainerTeam{
				ID:   4,
				Name: "New Team",
			},
			memberships: []*models.PortainerTeamMembership{},
			expectedTeam: Team{
				ID:        4,
				Name:      "New Team",
				MemberIDs: []int{},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := ConvertToTeam(tt.team, tt.memberships)

			if result.ID != tt.expectedTeam.ID {
				t.Errorf("ID mismatch: got %v, want %v", result.ID, tt.expectedTeam.ID)
			}

			if result.Name != tt.expectedTeam.Name {
				t.Errorf("Name mismatch: got %v, want %v", result.Name, tt.expectedTeam.Name)
			}

			if !reflect.DeepEqual(result.MemberIDs, tt.expectedTeam.MemberIDs) {
				t.Errorf("MemberIDs mismatch: got %v, want %v", result.MemberIDs, tt.expectedTeam.MemberIDs)
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/docs/design/202503-3-specific-update-tools.md:
--------------------------------------------------------------------------------

```markdown
# 202503-3: Specific tool for updates instead of a single update tool

**Date**: 29/03/2025

### Context
Initially, resource updates (such as access groups, environments, etc.) were handled through single, multi-purpose update tools that could modify multiple properties at once. This approach led to complex parameter handling and unclear behavior around optional values.

### Decision
Split update operations into multiple specific tools, each responsible for updating a single property or related set of properties. For example, instead of a single `updateAccessGroup` tool, create separate tools like:
- `updateAccessGroupName`
- `updateAccessGroupUserAccesses`
- `updateAccessGroupTeamAccesses`

### Rationale
1. **Parameter Clarity**
   - Each tool has clear, required parameters
   - No ambiguity between undefined parameters and empty values
   - Eliminates need for complex optional parameter handling

2. **Code Simplification**
   - Removes need for pointer types in parameter handling
   - Clearer validation of required parameters
   - Simpler implementation of each specific update operation

3. **Maintenance Benefits**
   - Each tool has a single responsibility
   - Easier to test individual update operations
   - Clearer documentation of available operations

4. **Model Interaction**
   - Models can clearly understand which property they're updating
   - More explicit about the changes being made
   - Better alignment with natural language commands

### Trade-offs

**Benefits**
- Clearer parameter requirements and validation
- Simpler code without pointer logic
- Better separation of concerns
- More explicit and focused tools
- Easier testing and maintenance

**Challenges**
- Multiple API calls needed for updating multiple properties
- Slightly increased network traffic for multi-property updates
- More tool definitions to maintain
- No atomic updates across multiple properties
- More tools might clutter the context of the model
- Some clients have a hard limit on the number of tools that can be used/enabled

### Notes
Performance impact of multiple API calls is considered acceptable given:
- Non-performance-critical context
- Relatively low frequency of update operations
- Benefits of simpler code and clearer behavior outweigh the overhead
```

--------------------------------------------------------------------------------
/cmd/portainer-mcp/mcp.go:
--------------------------------------------------------------------------------

```go
package main

import (
	"flag"

	"github.com/portainer/portainer-mcp/internal/mcp"
	"github.com/portainer/portainer-mcp/internal/tooldef"
	"github.com/rs/zerolog/log"
)

const defaultToolsPath = "tools.yaml"

var (
	Version   string
	BuildDate string
	Commit    string
)

func main() {
	log.Info().
		Str("version", Version).
		Str("build-date", BuildDate).
		Str("commit", Commit).
		Msg("Portainer MCP server")

	serverFlag := flag.String("server", "", "The Portainer server URL")
	tokenFlag := flag.String("token", "", "The authentication token for the Portainer server")
	toolsFlag := flag.String("tools", "", "The path to the tools YAML file")
	readOnlyFlag := flag.Bool("read-only", false, "Run in read-only mode")
	disableVersionCheckFlag := flag.Bool("disable-version-check", false, "Disable Portainer server version check")

	flag.Parse()

	if *serverFlag == "" || *tokenFlag == "" {
		log.Fatal().Msg("Both -server and -token flags are required")
	}

	toolsPath := *toolsFlag
	if toolsPath == "" {
		toolsPath = defaultToolsPath
	}

	// We first check if the tools.yaml file exists
	// We'll create it from the embedded version if it doesn't exist
	exists, err := tooldef.CreateToolsFileIfNotExists(toolsPath)
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create tools.yaml file")
	}

	if exists {
		log.Info().Msg("using existing tools.yaml file")
	} else {
		log.Info().Msg("created tools.yaml file")
	}

	log.Info().
		Str("portainer-host", *serverFlag).
		Str("tools-path", toolsPath).
		Bool("read-only", *readOnlyFlag).
		Bool("disable-version-check", *disableVersionCheckFlag).
		Msg("starting MCP server")

	server, err := mcp.NewPortainerMCPServer(*serverFlag, *tokenFlag, toolsPath, mcp.WithReadOnly(*readOnlyFlag), mcp.WithDisableVersionCheck(*disableVersionCheckFlag))
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create server")
	}

	server.AddEnvironmentFeatures()
	server.AddEnvironmentGroupFeatures()
	server.AddTagFeatures()
	server.AddStackFeatures()
	server.AddSettingsFeatures()
	server.AddUserFeatures()
	server.AddTeamFeatures()
	server.AddAccessGroupFeatures()
	server.AddDockerProxyFeatures()
	server.AddKubernetesProxyFeatures()

	err = server.Start()
	if err != nil {
		log.Fatal().Err(err).Msg("failed to start server")
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/stack_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"testing"
	"time"

	"reflect"

	"github.com/portainer/client-api-go/v2/pkg/models"
)

func TestConvertEdgeStackToStack(t *testing.T) {
	tests := []struct {
		name      string
		edgeStack *models.PortainereeEdgeStack
		want      Stack
	}{
		{
			name: "basic edge stack conversion",
			edgeStack: &models.PortainereeEdgeStack{
				ID:           1,
				Name:         "Web Application Stack",
				CreationDate: 1609459200, // 2021-01-01 00:00:00 UTC
				EdgeGroups:   []int64{1, 2, 3},
			},
			want: Stack{
				ID:                  1,
				Name:                "Web Application Stack",
				CreatedAt:           "2021-01-01T00:00:00Z",
				EnvironmentGroupIds: []int{1, 2, 3},
			},
		},
		{
			name: "edge stack with no groups",
			edgeStack: &models.PortainereeEdgeStack{
				ID:           2,
				Name:         "Empty Stack",
				CreationDate: 1640995200, // 2022-01-01 00:00:00 UTC
				EdgeGroups:   []int64{},
			},
			want: Stack{
				ID:                  2,
				Name:                "Empty Stack",
				CreatedAt:           "2022-01-01T00:00:00Z",
				EnvironmentGroupIds: []int{},
			},
		},
		{
			name: "edge stack with single group",
			edgeStack: &models.PortainereeEdgeStack{
				ID:           3,
				Name:         "Single Group Stack",
				CreationDate: 1672531200, // 2023-01-01 00:00:00 UTC
				EdgeGroups:   []int64{4},
			},
			want: Stack{
				ID:                  3,
				Name:                "Single Group Stack",
				CreatedAt:           "2023-01-01T00:00:00Z",
				EnvironmentGroupIds: []int{4},
			},
		},
		{
			name: "edge stack with current timestamp",
			edgeStack: &models.PortainereeEdgeStack{
				ID:           4,
				Name:         "Recent Stack",
				CreationDate: time.Now().Add(-24 * time.Hour).Unix(), // Yesterday
				EdgeGroups:   []int64{1, 2},
			},
			want: Stack{
				ID:                  4,
				Name:                "Recent Stack",
				CreatedAt:           time.Unix(time.Now().Add(-24*time.Hour).Unix(), 0).Format(time.RFC3339),
				EnvironmentGroupIds: []int{1, 2},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := ConvertEdgeStackToStack(tt.edgeStack)
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("ConvertEdgeStackToStack() = %v, want %v", got, tt.want)
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/docs/design_summary.md:
--------------------------------------------------------------------------------

```markdown
# Design Documentation Summary

This document provides a summary of key design decisions for the Portainer MCP project. Each decision is documented in detail in its own file.

## Design Decisions

| ID | Title | Date | Description |
|----|-------|------|-------------|
| [202503-1](design/202503-1-external-tools-file.md) | Using an external tools file for tool definition | 29/03/2025 | Externalizes tool definitions into a YAML file for improved maintainability |
| [202503-2](design/202503-2-tools-vs-mcp-resources.md) | Using tools to get resources instead of MCP resources | 29/03/2025 | Prefers tool-based resource access over MCP resources for better model control |
| [202503-3](design/202503-3-specific-update-tools.md) | Specific tool for updates instead of a single update tool | 29/03/2025 | Splits update operations into specific tools for clearer parameter handling |
| [202504-1](design/202504-1-embedded-tools-yaml.md) | Embedding tools.yaml in the binary | 08/04/2025 | Embeds the tools configuration file in the binary for simplified distribution |
| [202504-2](design/202504-2-tools-yaml-versioning.md) | Strict versioning for tools.yaml file | 08/04/2025 | Implements versioning for tools.yaml to prevent compatibility issues |
| [202504-3](design/202504-3-portainer-version-compatibility.md) | Pinning compatibility to a specific Portainer version | 08/04/2025 | Binds each release to a specific Portainer version for guaranteed compatibility |
| [202504-4](design/202504-4-read-only-mode.md) | Read-only mode for enhanced security | 09/04/2025 | Provides a read-only mode to restrict modification capabilities for security |

## How to Add a New Design Decision

1. Create a new file in the `docs/design/` directory following the format:
   - Filename: `YYYYMM-N-short-description.md` (e.g., `202505-1-feature-toggles.md`)
   - Where `YYYYMM` is the date (year-month), and `N` is a sequence number for that date

2. Use the standard template structure:
   ```
   # YYYYMM-N: Title

   **Date**: DD/MM/YYYY

   ### Context
   [Background and reasons for this decision]

   ### Decision
   [The decision that was made]

   ### Rationale
   [Explanation of why this decision was made]

   ### Trade-offs
   [Benefits and challenges of this approach]
   ```

3. Add the decision to the table in this summary document
```

--------------------------------------------------------------------------------
/internal/mcp/settings_test.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"
	"testing"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/stretchr/testify/assert"
)

func TestHandleGetSettings(t *testing.T) {
	tests := []struct {
		name          string
		settings      models.PortainerSettings
		mockError     error
		expectError   bool
		errorContains string
	}{
		{
			name: "successful settings retrieval",
			settings: models.PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: models.AuthenticationMethodInternal,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   true,
					ServerURL: "https://example.com",
				},
			},
			mockError:   nil,
			expectError: false,
		},
		{
			name:          "client error",
			settings:      models.PortainerSettings{},
			mockError:     assert.AnError,
			expectError:   true,
			errorContains: "failed to get settings",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Create mock client
			mockClient := new(MockPortainerClient)
			mockClient.On("GetSettings").Return(tt.settings, tt.mockError)

			// Create server with mock client
			srv := &PortainerMCPServer{
				srv:   server.NewMCPServer("Test Server", "1.0.0"),
				cli:   mockClient,
				tools: make(map[string]mcp.Tool),
			}

			// Get the handler
			handler := srv.HandleGetSettings()

			// Call the handler
			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 API errors")
				assert.Len(t, result.Content, 1)
				textContent, ok := result.Content[0].(mcp.TextContent)
				assert.True(t, ok, "Result content should be mcp.TextContent")
				if tt.errorContains != "" {
					assert.Contains(t, textContent.Text, tt.errorContains)
				}
			} 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)

				var settings models.PortainerSettings
				err = json.Unmarshal([]byte(textContent.Text), &settings)
				assert.NoError(t, err)
				assert.Equal(t, tt.settings, settings)
			}

			// Verify mock expectations
			mockClient.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/docs/design/202504-3-portainer-version-compatibility.md:
--------------------------------------------------------------------------------

```markdown
# 202504-3: Pinning compatibility to a specific Portainer version

**Date**: 08/04/2025

### Context
Portainer server does not implement API versioning, making it challenging to ensure compatibility between our software and different Portainer server versions. Each version of Portainer may have different API behaviors and endpoints, which could cause runtime errors or unexpected behavior if not properly managed.

### Decision
Maintain independent versioning for this software while explicitly pinning compatibility to a specific Portainer server version. The software will validate the Portainer server version at startup and fail fast if the detected version does not match the required version exactly. Documentation will clearly indicate which exact Portainer version is supported by each software release.

### Rationale
1. **Independent Release Cycle**
   - Software can be updated outside of the Portainer release lifecycle
   - Allows for bug fixes and features without waiting for Portainer releases
   - Enables more frequent iterations and improvements

2. **Exact Compatibility**
   - Each release will document the specific Portainer version it supports
   - Strict version checking at startup prevents compatibility issues
   - Ensures 100% compatibility with the supported API endpoints

3. **SDK Alignment**
   - Software will use a Go SDK version that matches exactly the supported Portainer version
   - Creates a precise binding between SDK capabilities and software functionality
   - Eliminates ambiguity about supported functionality

4. **Error Prevention**
   - Early validation of the exact Portainer version prevents any API compatibility issues
   - Users receive clear error messages when the version doesn't match
   - Completely eliminates support requests related to API incompatibilities

### Trade-offs

**Benefits**
- Flexible release schedule independent of Portainer
- Absolute certainty about compatibility requirements
- Fail-fast behavior for unsupported versions
- Predictable behavior with supported Portainer version
- Simplified testing against a single Portainer version

**Challenges**
- Users must upgrade/downgrade Portainer to the exact supported version
- Each software release requires a new version when supporting a new Portainer version
- More restrictive for users who can't easily change their Portainer version
- Overhead of version validation at startup
- Need to clearly communicate the exact supported version in all documentation
```

--------------------------------------------------------------------------------
/pkg/portainer/client/tag_test.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"
	"testing"

	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/stretchr/testify/assert"
)

func TestGetEnvironmentTags(t *testing.T) {
	tests := []struct {
		name          string
		mockTags      []*apimodels.PortainerTag
		mockError     error
		expectedTags  []models.EnvironmentTag
		expectedError bool
	}{
		{
			name: "successful retrieval",
			mockTags: []*apimodels.PortainerTag{
				{ID: 1, Name: "prod"},
				{ID: 2, Name: "dev"},
			},
			mockError: nil,
			expectedTags: []models.EnvironmentTag{
				{ID: 1, Name: "prod", EnvironmentIds: []int{}},
				{ID: 2, Name: "dev", EnvironmentIds: []int{}},
			},
			expectedError: false,
		},
		{
			name:          "empty tags list",
			mockTags:      []*apimodels.PortainerTag{},
			mockError:     nil,
			expectedTags:  []models.EnvironmentTag{},
			expectedError: false,
		},
		{
			name:          "api error",
			mockTags:      nil,
			mockError:     fmt.Errorf("api error"),
			expectedTags:  nil,
			expectedError: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			mockAPI.On("ListTags").Return(tt.mockTags, tt.mockError)

			client := &PortainerClient{
				cli: mockAPI,
			}

			tags, err := client.GetEnvironmentTags()

			if tt.expectedError {
				assert.Error(t, err)
			} else {
				assert.NoError(t, err)
				assert.Equal(t, tt.expectedTags, tags)
			}

			mockAPI.AssertExpectations(t)
		})
	}
}

func TestCreateEnvironmentTag(t *testing.T) {
	tests := []struct {
		name          string
		tagName       string
		mockID        int64
		mockError     error
		expectedID    int
		expectedError bool
	}{
		{
			name:          "successful creation",
			tagName:       "prod",
			mockID:        1,
			mockError:     nil,
			expectedID:    1,
			expectedError: false,
		},
		{
			name:          "api error",
			tagName:       "dev",
			mockID:        0,
			mockError:     fmt.Errorf("api error"),
			expectedID:    0,
			expectedError: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			mockAPI.On("CreateTag", tt.tagName).Return(tt.mockID, tt.mockError)

			client := &PortainerClient{
				cli: mockAPI,
			}

			id, err := client.CreateEnvironmentTag(tt.tagName)

			if tt.expectedError {
				assert.Error(t, err)
			} else {
				assert.NoError(t, err)
				assert.Equal(t, tt.expectedID, id)
			}

			mockAPI.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/internal/tooldef/tooldef_test.go:
--------------------------------------------------------------------------------

```go
package tooldef

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestCreateToolsFileIfNotExists(t *testing.T) {
	// Create a temporary directory for testing
	tempDir, err := os.MkdirTemp("", "tooldef-test")
	require.NoError(t, err, "Failed to create temporary directory")
	defer os.RemoveAll(tempDir)

	t.Run("File Does Not Exist", func(t *testing.T) {
		// Define a path for a non-existent file
		filePath := filepath.Join(tempDir, "new-tools.yaml")

		// Verify the file doesn't exist initially
		_, err := os.Stat(filePath)
		assert.True(t, os.IsNotExist(err), "File should not exist before test")

		exists, err := CreateToolsFileIfNotExists(filePath)
		require.NoError(t, err, "Function should not return an error")
		assert.False(t, exists, "Function should return false when creating a new file")

		// Verify the file was created
		_, err = os.Stat(filePath)
		assert.NoError(t, err, "File should exist after function call")

		// Verify the file has the embedded content
		content, err := os.ReadFile(filePath)
		require.NoError(t, err, "Should be able to read the created file")
		assert.Equal(t, ToolsFile, content, "File should contain the embedded tools content")
	})

	t.Run("File Already Exists", func(t *testing.T) {
		// Define a path for an existing file
		filePath := filepath.Join(tempDir, "existing-tools.yaml")

		// Create a custom file
		customContent := []byte("# Custom tools file content")
		err := os.WriteFile(filePath, customContent, 0644)
		require.NoError(t, err, "Failed to create test file")

		exists, err := CreateToolsFileIfNotExists(filePath)
		require.NoError(t, err, "Function should not return an error")
		assert.True(t, exists, "Function should return true when file already exists")

		// Verify the file content was not changed
		content, err := os.ReadFile(filePath)
		require.NoError(t, err, "Should be able to read the existing file")
		assert.Equal(t, customContent, content, "Function should not modify an existing file")
	})

	t.Run("Error During File Creation", func(t *testing.T) {
		// Create a path in a non-existent directory to force a file creation error
		nonExistentDir := filepath.Join(tempDir, "this-directory-does-not-exist", "neither-does-this-one")
		filePath := filepath.Join(nonExistentDir, "tools.yaml")

		exists, err := CreateToolsFileIfNotExists(filePath)
		assert.Error(t, err, "Function should return an error when file creation fails")
		assert.False(t, exists, "Function should return false when an error occurs")
	})
}

```

--------------------------------------------------------------------------------
/docs/design/202504-4-read-only-mode.md:
--------------------------------------------------------------------------------

```markdown
# 202504-4: Read-only mode for enhanced security

**Date**: 09/04/2025

### Context
Model Context Protocol (MCP) is a relatively new technology with varying levels of trust among infrastructure operators. There are significant concerns about potential security risks when allowing AI models to modify production resources. These concerns are heightened by the growing awareness of prompt injection attacks, model hallucinations, and other LLM-specific vulnerabilities that could be exploited to trigger unintended operations on critical infrastructure. Portainer often manages production container environments, making the security implications particularly serious.

### Decision
Implement a read-only flag that can be specified at application startup. When this flag is enabled, the application will only register and expose read-oriented tools, completely omitting any tools capable of modifying Portainer resources.

### Rationale
1. **Security Enhancement**
   - Eliminates risk of accidental or unauthorized modifications to production environments
   - Provides a safe mode for users to explore and monitor without modification capabilities
   - Creates a clear separation between monitoring and management use cases

2. **Operational Safety**
   - Enables safe usage in sensitive production environments
   - Reduces potential impact of prompt injection or model hallucination issues
   - Provides an additional layer of protection for critical infrastructure

3. **User Trust**
   - Addresses concerns of security-conscious users about potential write implications
   - Creates confidence that the application cannot modify resources when in read-only mode
   - Offers a path for skeptical users to start with limited capabilities before enabling full functionality

4. **Use Case Alignment**
   - Matches common use case of "explore first, modify later" workflow
   - Supports read-only scenarios like monitoring, auditing, and documentation
   - Creates a clear distinction between observability and management roles

### Trade-offs

**Benefits**
- Enhanced security posture for sensitive environments
- Reduced risk surface for production deployments
- Builds user trust through clear capability boundaries
- Better alignment with specific read-only use cases
- Allows progressive adoption starting with read-only mode

**Challenges**
- Need to categorize tools as read or write operations
- Additional startup mode to test and maintain
- Potential user confusion about available capabilities in each mode
- May require switching between modes for different workflows
- Reduced functionality in read-only mode may limit some complex scenarios
```

--------------------------------------------------------------------------------
/pkg/portainer/models/tag_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"testing"

	"github.com/portainer/client-api-go/v2/pkg/models"
)

func TestConvertTagToEnvironmentTag(t *testing.T) {
	tests := []struct {
		name         string
		portainerTag *models.PortainerTag
		want         EnvironmentTag
	}{
		{
			name: "basic tag conversion",
			portainerTag: &models.PortainerTag{
				ID:   1,
				Name: "Production",
				Endpoints: map[string]bool{
					"1": true,
					"2": true,
					"3": true,
				},
			},
			want: EnvironmentTag{
				ID:             1,
				Name:           "Production",
				EnvironmentIds: []int{1, 2, 3},
			},
		},
		{
			name: "tag with no endpoints",
			portainerTag: &models.PortainerTag{
				ID:        2,
				Name:      "Empty Tag",
				Endpoints: map[string]bool{},
			},
			want: EnvironmentTag{
				ID:             2,
				Name:           "Empty Tag",
				EnvironmentIds: []int{},
			},
		},
		{
			name: "tag with invalid endpoint ID",
			portainerTag: &models.PortainerTag{
				ID:   3,
				Name: "Mixed IDs",
				Endpoints: map[string]bool{
					"42":      true,
					"abc":     true, // Invalid ID, should be skipped
					"99":      true,
					"invalid": true, // Invalid ID, should be skipped
				},
			},
			want: EnvironmentTag{
				ID:             3,
				Name:           "Mixed IDs",
				EnvironmentIds: []int{42, 99},
			},
		},
		{
			name: "tag with single endpoint",
			portainerTag: &models.PortainerTag{
				ID:   4,
				Name: "Single Server",
				Endpoints: map[string]bool{
					"5": true,
				},
			},
			want: EnvironmentTag{
				ID:             4,
				Name:           "Single Server",
				EnvironmentIds: []int{5},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := ConvertTagToEnvironmentTag(tt.portainerTag)

			// Since the order of EnvironmentIds is not guaranteed due to map iteration,
			// we need to sort both slices before comparison
			if !compareEnvironmentTags(got, tt.want) {
				t.Errorf("ConvertTagToEnvironmentTag() = %v, want %v", got, tt.want)
			}
		})
	}
}

// compareEnvironmentTags compares two EnvironmentTag structs, handling the
// unordered nature of the EnvironmentIds slice
func compareEnvironmentTags(a, b EnvironmentTag) bool {
	if a.ID != b.ID || a.Name != b.Name || len(a.EnvironmentIds) != len(b.EnvironmentIds) {
		return false
	}

	// Create maps to check if all IDs exist in both slices
	aMap := make(map[int]bool)
	bMap := make(map[int]bool)

	for _, id := range a.EnvironmentIds {
		aMap[id] = true
	}

	for _, id := range b.EnvironmentIds {
		bMap[id] = true
		if !aMap[id] {
			return false
		}
	}

	// Check if all IDs in a exist in b
	for id := range aMap {
		if !bMap[id] {
			return false
		}
	}

	return true
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/environment.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
)

// GetEnvironments retrieves all environments from the Portainer server.
//
// Returns:
//   - A slice of Environment objects
//   - An error if the operation fails
func (c *PortainerClient) GetEnvironments() ([]models.Environment, error) {
	endpoints, err := c.cli.ListEndpoints()
	if err != nil {
		return nil, fmt.Errorf("failed to list endpoints: %w", err)
	}

	environments := make([]models.Environment, len(endpoints))
	for i, endpoint := range endpoints {
		environments[i] = models.ConvertEndpointToEnvironment(endpoint)
	}

	return environments, nil
}

// UpdateEnvironmentTags updates the tags associated with an environment.
//
// Parameters:
//   - id: The ID of the environment to update
//   - tagIds: A slice of tag IDs to associate with the environment
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateEnvironmentTags(id int, tagIds []int) error {
	tags := utils.IntToInt64Slice(tagIds)
	err := c.cli.UpdateEndpoint(int64(id),
		&tags,
		nil,
		nil,
	)
	if err != nil {
		return fmt.Errorf("failed to update environment tags: %w", err)
	}
	return nil
}

// UpdateEnvironmentUserAccesses updates the user access policies of an environment.
//
// Parameters:
//   - id: The ID of the environment to update
//   - userAccesses: Map of user IDs to their access level
//
// Valid access levels are:
//   - environment_administrator
//   - helpdesk_user
//   - standard_user
//   - readonly_user
//   - operator_user
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error {
	uac := utils.IntToInt64Map(userAccesses)
	err := c.cli.UpdateEndpoint(int64(id),
		nil,
		&uac,
		nil,
	)
	if err != nil {
		return fmt.Errorf("failed to update environment user accesses: %w", err)
	}
	return nil
}

// UpdateEnvironmentTeamAccesses updates the team access policies of an environment.
//
// Parameters:
//   - id: The ID of the environment to update
//   - teamAccesses: Map of team IDs to their access level
//
// Valid access levels are:
//   - environment_administrator
//   - helpdesk_user
//   - standard_user
//   - readonly_user
//   - operator_user
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error {
	tac := utils.IntToInt64Map(teamAccesses)
	err := c.cli.UpdateEndpoint(int64(id),
		nil,
		nil,
		&tac,
	)
	if err != nil {
		return fmt.Errorf("failed to update environment team accesses: %w", err)
	}
	return nil
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/stack.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
)

// GetStacks retrieves all stacks from the Portainer server.
// Stacks are the equivalent of Edge Stacks in Portainer.
//
// Returns:
//   - A slice of Stack objects
//   - An error if the operation fails
func (c *PortainerClient) GetStacks() ([]models.Stack, error) {
	edgeStacks, err := c.cli.ListEdgeStacks()
	if err != nil {
		return nil, fmt.Errorf("failed to list edge stacks: %w", err)
	}

	stacks := make([]models.Stack, len(edgeStacks))
	for i, es := range edgeStacks {
		stacks[i] = models.ConvertEdgeStackToStack(es)
	}

	return stacks, nil
}

// GetStackFile retrieves the file content of a stack from the Portainer server.
// Stacks are the equivalent of Edge Stacks in Portainer.
//
// Parameters:
//   - id: The ID of the stack to retrieve
//
// Returns:
//   - The file content of the stack (Compose file)
//   - An error if the operation fails
func (c *PortainerClient) GetStackFile(id int) (string, error) {
	file, err := c.cli.GetEdgeStackFile(int64(id))
	if err != nil {
		return "", fmt.Errorf("failed to get edge stack file: %w", err)
	}

	return file, nil
}

// CreateStack creates a new stack on the Portainer server.
// This function specifically creates a Docker Compose stack.
// Stacks are the equivalent of Edge Stacks in Portainer.
//
// Parameters:
//   - name: The name of the stack
//   - file: The file content of the stack (Compose file)
//   - environmentGroupIds: A slice of environment group IDs to include in the stack
//
// Returns:
//   - The ID of the created stack
//   - An error if the operation fails
func (c *PortainerClient) CreateStack(name, file string, environmentGroupIds []int) (int, error) {
	id, err := c.cli.CreateEdgeStack(name, file, utils.IntToInt64Slice(environmentGroupIds))
	if err != nil {
		return 0, fmt.Errorf("failed to create edge stack: %w", err)
	}

	return int(id), nil
}

// UpdateStack updates an existing stack on the Portainer server.
// This function specifically updates a Docker Compose stack.
// Stacks are the equivalent of Edge Stacks in Portainer.
//
// Parameters:
//   - id: The ID of the stack to update
//   - file: The file content of the stack (Compose file)
//   - environmentGroupIds: A slice of environment group IDs to include in the stack
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateStack(id int, file string, environmentGroupIds []int) error {
	err := c.cli.UpdateEdgeStack(int64(id), file, utils.IntToInt64Slice(environmentGroupIds))
	if err != nil {
		return fmt.Errorf("failed to update edge stack: %w", err)
	}

	return nil
}

```

--------------------------------------------------------------------------------
/internal/mcp/docker.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"fmt"
	"io"
	"strings"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/portainer/portainer-mcp/pkg/toolgen"
)

func (s *PortainerMCPServer) AddDockerProxyFeatures() {
	if !s.readOnly {
		s.addToolIfExists(ToolDockerProxy, s.HandleDockerProxy())
	}
}

func (s *PortainerMCPServer) HandleDockerProxy() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		environmentId, err := parser.GetInt("environmentId", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
		}

		method, err := parser.GetString("method", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid method parameter", err), nil
		}
		if !isValidHTTPMethod(method) {
			return mcp.NewToolResultError(fmt.Sprintf("invalid method: %s", method)), nil
		}

		dockerAPIPath, err := parser.GetString("dockerAPIPath", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid dockerAPIPath parameter", err), nil
		}
		if !strings.HasPrefix(dockerAPIPath, "/") {
			return mcp.NewToolResultError("dockerAPIPath must start with a leading slash"), nil
		}

		queryParams, err := parser.GetArrayOfObjects("queryParams", false)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
		}
		queryParamsMap, err := parseKeyValueMap(queryParams)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
		}

		headers, err := parser.GetArrayOfObjects("headers", false)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
		}
		headersMap, err := parseKeyValueMap(headers)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
		}

		body, err := parser.GetString("body", false)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid body parameter", err), nil
		}

		opts := models.DockerProxyRequestOptions{
			EnvironmentID: environmentId,
			Path:          dockerAPIPath,
			Method:        method,
			QueryParams:   queryParamsMap,
			Headers:       headersMap,
		}

		if body != "" {
			opts.Body = strings.NewReader(body)
		}

		response, err := s.cli.ProxyDockerRequest(opts)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to send Docker API request", err), nil
		}

		responseBody, err := io.ReadAll(response.Body)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to read Docker API response", err), nil
		}

		return mcp.NewToolResultText(string(responseBody)), nil
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/environment.go:
--------------------------------------------------------------------------------

```go
package models

import (
	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
)

type Environment struct {
	ID           int            `json:"id"`
	Name         string         `json:"name"`
	Status       string         `json:"status"`
	Type         string         `json:"type"`
	TagIds       []int          `json:"tag_ids"`
	UserAccesses map[int]string `json:"user_accesses"`
	TeamAccesses map[int]string `json:"team_accesses"`
}

// Environment status constants
const (
	EnvironmentStatusActive   = "active"
	EnvironmentStatusInactive = "inactive"
	EnvironmentStatusUnknown  = "unknown"
)

// Environment type constants
const (
	EnvironmentTypeDockerLocal         = "docker-local"
	EnvironmentTypeDockerAgent         = "docker-agent"
	EnvironmentTypeAzureACI            = "azure-aci"
	EnvironmentTypeDockerEdgeAgent     = "docker-edge-agent"
	EnvironmentTypeKubernetesLocal     = "kubernetes-local"
	EnvironmentTypeKubernetesAgent     = "kubernetes-agent"
	EnvironmentTypeKubernetesEdgeAgent = "kubernetes-edge-agent"
	EnvironmentTypeUnknown             = "unknown"
)

func ConvertEndpointToEnvironment(rawEndpoint *apimodels.PortainereeEndpoint) Environment {
	return Environment{
		ID:           int(rawEndpoint.ID),
		Name:         rawEndpoint.Name,
		Status:       convertEnvironmentStatus(rawEndpoint),
		Type:         convertEnvironmentType(rawEndpoint),
		TagIds:       utils.Int64ToIntSlice(rawEndpoint.TagIds),
		UserAccesses: convertAccesses(rawEndpoint.UserAccessPolicies),
		TeamAccesses: convertAccesses(rawEndpoint.TeamAccessPolicies),
	}
}

func convertEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string {
	if rawEndpoint.Type == 4 || rawEndpoint.Type == 7 {
		return convertEdgeEnvironmentStatus(rawEndpoint)
	}
	return convertStandardEnvironmentStatus(rawEndpoint)
}

func convertStandardEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string {
	switch rawEndpoint.Status {
	case 1:
		return EnvironmentStatusActive
	case 2:
		return EnvironmentStatusInactive
	default:
		return EnvironmentStatusUnknown
	}
}

func convertEdgeEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string {
	if rawEndpoint.Heartbeat {
		return EnvironmentStatusActive
	}
	return EnvironmentStatusInactive
}

func convertEnvironmentType(rawEndpoint *apimodels.PortainereeEndpoint) string {
	switch rawEndpoint.Type {
	case 1:
		return EnvironmentTypeDockerLocal
	case 2:
		return EnvironmentTypeDockerAgent
	case 3:
		return EnvironmentTypeAzureACI
	case 4:
		return EnvironmentTypeDockerEdgeAgent
	case 5:
		return EnvironmentTypeKubernetesLocal
	case 6:
		return EnvironmentTypeKubernetesAgent
	case 7:
		return EnvironmentTypeKubernetesEdgeAgent
	default:
		return EnvironmentTypeUnknown
	}
}

```

--------------------------------------------------------------------------------
/token.sh:
--------------------------------------------------------------------------------

```bash
#!/bin/bash

# This script calculates the estimated token count for a given set of tools
# and a sample message using the Anthropic API's /v1/messages/count_tokens endpoint.
# It reads tool definitions from a JSON file generated by the `cmd/token-count` Go program.
#
# Requires:
#   - curl : For making HTTP requests to the Anthropic API.
#   - jq   : For constructing the JSON payload from the input file.
#
# Usage:
#   ./token.sh -k <YOUR_ANTHROPIC_API_KEY> -i <path/to/your/tools.json>
#
# Mandatory Arguments:
#   -k, --api-key      : Your Anthropic API key.
#   -i, --input-json   : Path to the JSON file containing the tool definitions.
#                        This file should be an array of tool objects, each having
#                        `name`, `description`, and `input_schema` fields.
#
# Example:
#   # Assuming tools.json is in the current directory
#   ./token.sh -k sk-ant-xxxxxxxx -i tools.json
#
# Output:
#   The script outputs the JSON response from the Anthropic API,
#   which typically includes the calculated token count.

# Default values
API_KEY=""
INPUT_JSON=""

# Parse command-line arguments
TEMP=$(getopt -o k:i: --long api-key:,input-json: -n 'token.sh' -- "$@")
if [ $? != 0 ] ; then echo "Terminating..." >&2 ; exit 1 ; fi

# Note the quotes around `$TEMP`: they are essential!
eval set -- "$TEMP"

while true; do
  case "$1" in
    -k | --api-key ) API_KEY="$2"; shift 2 ;;
    -i | --input-json ) INPUT_JSON="$2"; shift 2 ;;
    -- ) shift; break ;;
    * ) break ;;
  esac
done

# Validate mandatory arguments
if [ -z "$API_KEY" ]; then
    echo "Error: API Key is mandatory. Use -k or --api-key." >&2
    exit 1
fi

if [ -z "$INPUT_JSON" ]; then
    echo "Error: Input JSON file path is mandatory. Use -i or --input-json." >&2
    exit 1
fi

if [ ! -f "$INPUT_JSON" ]; then
    echo "Error: Input JSON file not found: $INPUT_JSON" >&2
    exit 1
fi

# Read tools definition from the input JSON file
TOOLS_JSON=$(cat "$INPUT_JSON")

# Construct the JSON payload using jq
# Note: We keep the example message structure for now.
# We pass the tools JSON as a string argument to jq and use --argjson to parse it.
JSON_PAYLOAD=$(jq -n --argjson tools "$TOOLS_JSON" '{
  model: "claude-3-7-sonnet-20250219",
  tools: $tools,
  messages: [
    {
      role: "user",
      content: "Show me a list of Portainer environments."
    }
  ]
}')

# Check if jq succeeded
if [ $? != 0 ]; then
    echo "Error: Failed to construct JSON payload with jq. Is the input JSON valid?" >&2
    exit 1
fi


# Make the API call
curl https://api.anthropic.com/v1/messages/count_tokens \
    --header "x-api-key: $API_KEY" \
    --header "content-type: application/json" \
    --header "anthropic-version: 2023-06-01" \
    --data "$JSON_PAYLOAD"

echo # Add a newline for cleaner output

```

--------------------------------------------------------------------------------
/pkg/portainer/client/team.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
)

// GetTeams retrieves all teams from the Portainer server.
//
// Returns:
//   - A slice of Team objects containing team information
//   - An error if the operation fails
func (c *PortainerClient) GetTeams() ([]models.Team, error) {
	portainerTeams, err := c.cli.ListTeams()
	if err != nil {
		return nil, fmt.Errorf("failed to list teams: %w", err)
	}

	// Get team memberships to populate team members
	memberships, err := c.cli.ListTeamMemberships()
	if err != nil {
		return nil, fmt.Errorf("failed to list team memberships: %w", err)
	}

	teams := make([]models.Team, len(portainerTeams))
	for i, team := range portainerTeams {
		teams[i] = models.ConvertToTeam(team, memberships)
	}

	return teams, nil
}

// UpdateTeamName updates the name of a team.
//
// Parameters:
//   - id: The ID of the team to update
//   - name: The new name for the team
func (c *PortainerClient) UpdateTeamName(id int, name string) error {
	return c.cli.UpdateTeamName(id, name)
}

// CreateTeam creates a new team.
//
// Parameters:
//   - name: The name of the team
//
// Returns:
//   - The ID of the created team
//   - An error if the operation fails
func (c *PortainerClient) CreateTeam(name string) (int, error) {
	id, err := c.cli.CreateTeam(name)
	if err != nil {
		return 0, fmt.Errorf("failed to create team: %w", err)
	}

	return int(id), nil
}

// UpdateTeamMembers updates the members of a team.
//
// Parameters:
//   - teamId: The ID of the team to update
//   - userIds: The IDs of the users associated with the team
func (c *PortainerClient) UpdateTeamMembers(teamId int, userIds []int) error {
	memberships, err := c.cli.ListTeamMemberships()
	if err != nil {
		return fmt.Errorf("failed to list team memberships: %w", err)
	}

	// Track which users are already members of the team
	existingMembers := make(map[int]bool)

	// First, handle existing memberships
	for _, membership := range memberships {
		if membership.TeamID == int64(teamId) {
			userID := membership.UserID
			existingMembers[int(userID)] = true

			// Check if this user should remain in the team
			shouldKeep := false
			for _, id := range userIds {
				if id == int(userID) {
					shouldKeep = true
					break
				}
			}

			// If user should not remain in the team, delete the membership
			if !shouldKeep {
				if err := c.cli.DeleteTeamMembership(int(membership.ID)); err != nil {
					return fmt.Errorf("failed to delete team membership for user %d: %w", userID, err)
				}
			}
		}
	}

	// Then, create memberships for new users
	for _, userID := range userIds {
		// Skip if user is already a member
		if existingMembers[userID] {
			continue
		}

		// Create new membership for this user
		if err := c.cli.CreateTeamMembership(teamId, userID); err != nil {
			return fmt.Errorf("failed to create team membership for user %d: %w", userID, err)
		}
	}

	return nil
}

```

--------------------------------------------------------------------------------
/internal/mcp/team.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/toolgen"
)

func (s *PortainerMCPServer) AddTeamFeatures() {
	s.addToolIfExists(ToolListTeams, s.HandleGetTeams())

	if !s.readOnly {
		s.addToolIfExists(ToolCreateTeam, s.HandleCreateTeam())
		s.addToolIfExists(ToolUpdateTeamName, s.HandleUpdateTeamName())
		s.addToolIfExists(ToolUpdateTeamMembers, s.HandleUpdateTeamMembers())
	}
}

func (s *PortainerMCPServer) HandleCreateTeam() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		name, err := parser.GetString("name", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
		}

		teamID, err := s.cli.CreateTeam(name)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to create team", err), nil
		}

		return mcp.NewToolResultText(fmt.Sprintf("Team created successfully with ID: %d", teamID)), nil
	}
}

func (s *PortainerMCPServer) HandleGetTeams() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		teams, err := s.cli.GetTeams()
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get teams", err), nil
		}

		data, err := json.Marshal(teams)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to marshal teams", err), nil
		}

		return mcp.NewToolResultText(string(data)), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateTeamName() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		name, err := parser.GetString("name", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
		}

		err = s.cli.UpdateTeamName(id, name)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update team name", err), nil
		}

		return mcp.NewToolResultText("Team name updated successfully"), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateTeamMembers() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		userIDs, err := parser.GetArrayOfIntegers("userIds", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid userIds parameter", err), nil
		}

		err = s.cli.UpdateTeamMembers(id, userIDs)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update team members", err), nil
		}

		return mcp.NewToolResultText("Team members updated successfully"), nil
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/docker_test.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"strings"
	"testing"

	"github.com/portainer/client-api-go/v2/client"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/stretchr/testify/assert"
)

func TestProxyDockerRequest(t *testing.T) {
	tests := []struct {
		name             string
		environmentId    int
		opts             models.DockerProxyRequestOptions
		mockResponse     *http.Response
		mockError        error
		expectedError    bool
		expectedStatus   int
		expectedRespBody string
	}{
		{
			name: "GET request with query parameters",
			opts: models.DockerProxyRequestOptions{
				EnvironmentID: 1,
				Method:        "GET",
				Path:          "/images/json",
				QueryParams:   map[string]string{"all": "true", "filter": "dangling"},
			},
			mockResponse: &http.Response{
				StatusCode: http.StatusOK,
				Body:       io.NopCloser(strings.NewReader(`[{"Id":"img1"}]`)),
			},
			mockError:        nil,
			expectedError:    false,
			expectedStatus:   http.StatusOK,
			expectedRespBody: `[{"Id":"img1"}]`,
		},
		{
			name: "POST request with custom headers",
			opts: models.DockerProxyRequestOptions{
				EnvironmentID: 2,
				Method:        "POST",
				Path:          "/networks/create",
				Headers:       map[string]string{"X-Custom-Header": "value1", "Authorization": "Bearer token"},
				Body:          bytes.NewBufferString(`{"Name": "my-network"}`),
			},
			mockResponse: &http.Response{
				StatusCode: http.StatusCreated,
				Body:       io.NopCloser(strings.NewReader(`{"Id": "net1"}`)),
			},
			mockError:        nil,
			expectedError:    false,
			expectedStatus:   http.StatusCreated,
			expectedRespBody: `{"Id": "net1"}`,
		},
		{
			name: "API error",
			opts: models.DockerProxyRequestOptions{
				EnvironmentID: 3,
				Method:        "GET",
				Path:          "/version",
			},
			mockResponse:     nil,
			mockError:        errors.New("failed to proxy request"),
			expectedError:    true,
			expectedStatus:   0,  // Not applicable
			expectedRespBody: "", // Not applicable
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			opts := client.ProxyRequestOptions{
				Method:      tt.opts.Method,
				APIPath:     tt.opts.Path,
				QueryParams: tt.opts.QueryParams,
				Headers:     tt.opts.Headers,
				Body:        tt.opts.Body,
			}
			mockAPI.On("ProxyDockerRequest", tt.opts.EnvironmentID, opts).Return(tt.mockResponse, tt.mockError)

			client := &PortainerClient{cli: mockAPI}

			resp, err := client.ProxyDockerRequest(tt.opts)
			if tt.expectedError {
				assert.Error(t, err)
				assert.EqualError(t, err, tt.mockError.Error())
				assert.Nil(t, resp)
			} else {
				assert.NoError(t, err)
				assert.NotNil(t, resp)
				assert.Equal(t, tt.expectedStatus, resp.StatusCode)

				// Read and verify the response body
				if assert.NotNil(t, resp.Body) { // Ensure body is not nil before reading
					defer resp.Body.Close()
					bodyBytes, readErr := io.ReadAll(resp.Body)
					assert.NoError(t, readErr)
					assert.Equal(t, tt.expectedRespBody, string(bodyBytes))
				}
			}

			mockAPI.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/utils/utils_test.go:
--------------------------------------------------------------------------------

```go
package utils

import (
	"reflect"
	"testing"
)

func TestInt64ToIntSlice(t *testing.T) {
	tests := []struct {
		name   string
		int64s []int64
		want   []int
	}{
		{
			name:   "empty slice",
			int64s: []int64{},
			want:   []int{},
		},
		{
			name:   "single element",
			int64s: []int64{42},
			want:   []int{42},
		},
		{
			name:   "multiple elements",
			int64s: []int64{1, 2, 3, 4, 5},
			want:   []int{1, 2, 3, 4, 5},
		},
		{
			name:   "large numbers",
			int64s: []int64{1000000000, 2000000000},
			want:   []int{1000000000, 2000000000},
		},
		{
			name:   "negative numbers",
			int64s: []int64{-1, -10, -100},
			want:   []int{-1, -10, -100},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Int64ToIntSlice(tt.int64s)
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("Int64ToIntSlice() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestIntToInt64Slice(t *testing.T) {
	tests := []struct {
		name string
		ints []int
		want []int64
	}{
		{
			name: "empty slice",
			ints: []int{},
			want: []int64{},
		},
		{
			name: "single element",
			ints: []int{42},
			want: []int64{42},
		},
		{
			name: "multiple elements",
			ints: []int{1, 2, 3, 4, 5},
			want: []int64{1, 2, 3, 4, 5},
		},
		{
			name: "large numbers",
			ints: []int{1000000000, 2000000000},
			want: []int64{1000000000, 2000000000},
		},
		{
			name: "negative numbers",
			ints: []int{-1, -10, -100},
			want: []int64{-1, -10, -100},
		},
		{
			name: "max int32 value",
			ints: []int{2147483647},
			want: []int64{2147483647},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := IntToInt64Slice(tt.ints)
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("IntToInt64Slice() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestIntToInt64Map(t *testing.T) {
	tests := []struct {
		name  string
		input map[int]string
		want  map[int64]string
	}{
		{
			name:  "empty map",
			input: map[int]string{},
			want:  map[int64]string{},
		},
		{
			name: "single key-value pair",
			input: map[int]string{
				1: "one",
			},
			want: map[int64]string{
				int64(1): "one",
			},
		},
		{
			name: "multiple key-value pairs",
			input: map[int]string{
				1: "one",
				2: "two",
				3: "three",
			},
			want: map[int64]string{
				int64(1): "one",
				int64(2): "two",
				int64(3): "three",
			},
		},
		{
			name: "negative keys",
			input: map[int]string{
				-1: "minus one",
				0:  "zero",
				1:  "one",
			},
			want: map[int64]string{
				int64(-1): "minus one",
				int64(0):  "zero",
				int64(1):  "one",
			},
		},
		{
			name: "large numbers",
			input: map[int]string{
				1000000: "million",
				9999999: "big number",
			},
			want: map[int64]string{
				int64(1000000): "million",
				int64(9999999): "big number",
			},
		},
		{
			name: "empty strings",
			input: map[int]string{
				1: "",
				2: "",
			},
			want: map[int64]string{
				int64(1): "",
				int64(2): "",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := IntToInt64Map(tt.input)
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("IntToInt64Map() = %v, want %v", got, tt.want)
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/group.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
)

// GetEnvironmentGroups retrieves all environment groups from the Portainer server.
// Environment groups are the equivalent of Edge Groups in Portainer.
//
// Returns:
//   - A slice of Group objects
//   - An error if the operation fails
func (c *PortainerClient) GetEnvironmentGroups() ([]models.Group, error) {
	edgeGroups, err := c.cli.ListEdgeGroups()
	if err != nil {
		return nil, fmt.Errorf("failed to list edge groups: %w", err)
	}

	groups := make([]models.Group, len(edgeGroups))
	for i, eg := range edgeGroups {
		groups[i] = models.ConvertEdgeGroupToGroup(eg)
	}

	return groups, nil
}

// CreateEnvironmentGroup creates a new environment group on the Portainer server.
// Environment groups are the equivalent of Edge Groups in Portainer.
// Parameters:
//   - name: The name of the environment group
//   - environmentIds: A slice of environment IDs to include in the group
//
// Returns:
//   - The ID of the created environment group
//   - An error if the operation fails
func (c *PortainerClient) CreateEnvironmentGroup(name string, environmentIds []int) (int, error) {
	id, err := c.cli.CreateEdgeGroup(name, utils.IntToInt64Slice(environmentIds))
	if err != nil {
		return 0, fmt.Errorf("failed to create environment group: %w", err)
	}

	return int(id), nil
}

// UpdateEnvironmentGroupName updates the name of an existing environment group.
// Environment groups are the equivalent of Edge Groups in Portainer.
//
// Parameters:
//   - id: The ID of the environment group to update
//   - name: The new name for the environment group
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateEnvironmentGroupName(id int, name string) error {
	err := c.cli.UpdateEdgeGroup(int64(id), &name, nil, nil)
	if err != nil {
		return fmt.Errorf("failed to update environment group name: %w", err)
	}
	return nil
}

// UpdateEnvironmentGroupEnvironments updates the environments associated with an environment group.
// Environment groups are the equivalent of Edge Groups in Portainer.
//
// Parameters:
//   - id: The ID of the environment group to update
//   - environmentIds: A slice of environment IDs to include in the group
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error {
	envs := utils.IntToInt64Slice(environmentIds)
	err := c.cli.UpdateEdgeGroup(int64(id), nil, &envs, nil)
	if err != nil {
		return fmt.Errorf("failed to update environment group environments: %w", err)
	}
	return nil
}

// UpdateEnvironmentGroupTags updates the tags associated with an environment group.
// Environment groups are the equivalent of Edge Groups in Portainer.
//
// Parameters:
//   - id: The ID of the environment group to update
//   - tagIds: A slice of tag IDs to include in the group
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateEnvironmentGroupTags(id int, tagIds []int) error {
	tags := utils.IntToInt64Slice(tagIds)
	err := c.cli.UpdateEdgeGroup(int64(id), nil, nil, &tags)
	if err != nil {
		return fmt.Errorf("failed to update environment group tags: %w", err)
	}
	return nil
}

```

--------------------------------------------------------------------------------
/pkg/toolgen/param.go:
--------------------------------------------------------------------------------

```go
package toolgen

import (
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
)

// ParameterParser provides methods to safely extract parameters from request arguments
type ParameterParser struct {
	args map[string]any
}

// NewParameterParser creates a new parameter parser for the given request
func NewParameterParser(request mcp.CallToolRequest) *ParameterParser {
	return &ParameterParser{
		args: request.GetArguments(),
	}
}

// GetString extracts a string parameter from the request
func (p *ParameterParser) GetString(name string, required bool) (string, error) {
	value, ok := p.args[name]
	if !ok || value == nil {
		if required {
			return "", fmt.Errorf("%s is required", name)
		}
		return "", nil
	}

	strValue, ok := value.(string)
	if !ok {
		return "", fmt.Errorf("%s must be a string", name)
	}

	return strValue, nil
}

// GetNumber extracts a number parameter from the request
func (p *ParameterParser) GetNumber(name string, required bool) (float64, error) {
	value, ok := p.args[name]
	if !ok || value == nil {
		if required {
			return 0, fmt.Errorf("%s is required", name)
		}
		return 0, nil
	}

	numValue, ok := value.(float64)
	if !ok {
		return 0, fmt.Errorf("%s must be a number", name)
	}

	return numValue, nil
}

// GetInt extracts an integer parameter from the request
func (p *ParameterParser) GetInt(name string, required bool) (int, error) {
	num, err := p.GetNumber(name, required)
	if err != nil {
		return 0, err
	}
	return int(num), nil
}

// GetBoolean extracts a boolean parameter from the request
func (p *ParameterParser) GetBoolean(name string, required bool) (bool, error) {
	value, ok := p.args[name]
	if !ok || value == nil {
		if required {
			return false, fmt.Errorf("%s is required", name)
		}
		return false, nil
	}

	boolValue, ok := value.(bool)
	if !ok {
		return false, fmt.Errorf("%s must be a boolean", name)
	}

	return boolValue, nil
}

// GetArrayOfIntegers extracts an array of numbers parameter from the request
func (p *ParameterParser) GetArrayOfIntegers(name string, required bool) ([]int, error) {
	value, ok := p.args[name]
	if !ok || value == nil {
		if required {
			return nil, fmt.Errorf("%s is required", name)
		}
		return []int{}, nil
	}

	arrayValue, ok := value.([]any)
	if !ok {
		return nil, fmt.Errorf("%s must be an array", name)
	}

	return parseArrayOfIntegers(arrayValue)
}

// GetArrayOfObjects extracts an array of objects parameter from the request
func (p *ParameterParser) GetArrayOfObjects(name string, required bool) ([]any, error) {
	value, ok := p.args[name]
	if !ok || value == nil {
		if required {
			return nil, fmt.Errorf("%s is required", name)
		}
		return []any{}, nil
	}

	arrayValue, ok := value.([]any)
	if !ok {
		return nil, fmt.Errorf("%s must be an array", name)
	}

	return arrayValue, nil
}

// parseArrayOfIntegers converts a slice of any type to a slice of integers.
// Returns an error if any value cannot be parsed as an integer.
//
// Example:
//
//	ids, err := parseArrayOfIntegers([]any{1, 2, 3})
//	// ids = []int{1, 2, 3}
func parseArrayOfIntegers(array []any) ([]int, error) {
	result := make([]int, 0, len(array))

	for _, item := range array {
		idFloat, ok := item.(float64)
		if !ok {
			return nil, fmt.Errorf("failed to parse '%v' as integer", item)
		}
		result = append(result, int(idFloat))
	}

	return result, nil
}

```

--------------------------------------------------------------------------------
/internal/k8sutil/stripper.go:
--------------------------------------------------------------------------------

```go
package k8sutil

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

// removeManagedFieldsFromUnstructuredObject is a helper function that modifies an Unstructured object in place
// by removing the managedFields attribute from its metadata.
func removeManagedFieldsFromUnstructuredObject(obj *unstructured.Unstructured) error {
	if obj == nil || obj.Object == nil {
		return nil // Nothing to do
	}

	metadata, found, err := unstructured.NestedFieldCopy(obj.Object, "metadata")
	if err != nil {
		return fmt.Errorf("error fetching metadata for object %s (%s): %w", obj.GetName(), obj.GetKind(), err)
	}
	if !found {
		return nil // Metadata not found, nothing to do
	}

	metadataMap, ok := metadata.(map[string]any)
	if !ok {
		return fmt.Errorf("metadata for object %s (%s) is not in the expected map format", obj.GetName(), obj.GetKind())
	}

	// Delete the managedFields key from the metadata map
	delete(metadataMap, "managedFields")

	// TODO: Consider also removing other verbose fields here, e.g., ownerReferences, if needed.
	// delete(metadataMap, "ownerReferences")

	// Set the modified metadata back to the object
	err = unstructured.SetNestedField(obj.Object, metadataMap, "metadata")
	if err != nil {
		return fmt.Errorf("error setting modified metadata for object %s (%s): %w", obj.GetName(), obj.GetKind(), err)
	}
	return nil
}

// ProcessRawKubernetesAPIResponse takes an HTTP response, processes the JSON body,
// removes managedFields (and potentially other verbose metadata) from any Kubernetes resource(s) found,
// and returns the modified JSON bytes.
func ProcessRawKubernetesAPIResponse(httpResp *http.Response) ([]byte, error) {
	if httpResp == nil {
		return nil, fmt.Errorf("http response is nil")
	}
	if httpResp.Body == nil {
		if httpResp.StatusCode != http.StatusNoContent && httpResp.ContentLength != 0 {
			return nil, fmt.Errorf("http response body is nil but content was expected (status: %s)", httpResp.Status)
		}
		return []byte{}, nil // Return empty bytes if no body and appropriate status
	}
	defer httpResp.Body.Close()

	bodyBytes, err := io.ReadAll(httpResp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response body: %w", err)
	}

	if len(bodyBytes) == 0 {
		return bodyBytes, nil // Valid empty body
	}

	uObj := &unstructured.Unstructured{}
	if err := uObj.UnmarshalJSON(bodyBytes); err != nil {
		trimmedBody := string(bodyBytes)
		if trimmedBody == "{}" || trimmedBody == "[]" {
			return bodyBytes, nil // Valid empty JSON object/array
		}
		return nil, fmt.Errorf("failed to unmarshal JSON into Unstructured: %w. Body: %s", err, string(bodyBytes))
	}

	if uObj.IsList() {
		list, err := uObj.ToList()
		if err != nil {
			return nil, fmt.Errorf("failed to convert Unstructured to UnstructuredList: %w", err)
		}

		for i := range list.Items {
			if err := removeManagedFieldsFromUnstructuredObject(&list.Items[i]); err != nil {
				return nil, fmt.Errorf("failed to remove managedFields from item %d in list: %w", i, err)
			}
		}
		return json.Marshal(list)
	} else {
		if len(uObj.Object) == 0 {
			return bodyBytes, nil // Empty object, nothing to process
		}
		if err := removeManagedFieldsFromUnstructuredObject(uObj); err != nil {
			return nil, fmt.Errorf("failed to remove managedFields from single object: %w", err)
		}
		return json.Marshal(uObj)
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/user_test.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"errors"
	"testing"

	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/stretchr/testify/assert"
)

func TestGetUsers(t *testing.T) {
	tests := []struct {
		name          string
		mockUsers     []*apimodels.PortainereeUser
		mockError     error
		expected      []models.User
		expectedError bool
	}{
		{
			name: "successful retrieval - all role types",
			mockUsers: []*apimodels.PortainereeUser{
				{
					ID:       1,
					Username: "admin_user",
					Role:     1, // admin
				},
				{
					ID:       2,
					Username: "regular_user",
					Role:     2, // user
				},
				{
					ID:       3,
					Username: "edge_admin_user",
					Role:     3, // edge_admin
				},
				{
					ID:       4,
					Username: "unknown_role_user",
					Role:     0, // unknown
				},
			},
			expected: []models.User{
				{
					ID:       1,
					Username: "admin_user",
					Role:     models.UserRoleAdmin,
				},
				{
					ID:       2,
					Username: "regular_user",
					Role:     models.UserRoleUser,
				},
				{
					ID:       3,
					Username: "edge_admin_user",
					Role:     models.UserRoleEdgeAdmin,
				},
				{
					ID:       4,
					Username: "unknown_role_user",
					Role:     models.UserRoleUnknown,
				},
			},
		},
		{
			name:      "empty users",
			mockUsers: []*apimodels.PortainereeUser{},
			expected:  []models.User{},
		},
		{
			name:          "list error",
			mockError:     errors.New("failed to list users"),
			expectedError: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			mockAPI.On("ListUsers").Return(tt.mockUsers, tt.mockError)

			client := &PortainerClient{cli: mockAPI}

			users, err := client.GetUsers()

			if tt.expectedError {
				assert.Error(t, err)
				return
			}
			assert.NoError(t, err)
			assert.Equal(t, tt.expected, users)
			mockAPI.AssertExpectations(t)
		})
	}
}

func TestUpdateUserRole(t *testing.T) {
	tests := []struct {
		name          string
		userID        int
		role          string
		expectedRole  int64
		mockError     error
		expectedError bool
	}{
		{
			name:         "update to admin role",
			userID:       1,
			role:         models.UserRoleAdmin,
			expectedRole: 1,
		},
		{
			name:         "update to regular user role",
			userID:       2,
			role:         models.UserRoleUser,
			expectedRole: 2,
		},
		{
			name:         "update to edge admin role",
			userID:       3,
			role:         models.UserRoleEdgeAdmin,
			expectedRole: 3,
		},
		{
			name:          "invalid role",
			userID:        4,
			role:          "invalid_role",
			expectedError: true,
		},
		{
			name:          "update error",
			userID:        5,
			role:          models.UserRoleAdmin,
			expectedRole:  1,
			mockError:     errors.New("failed to update user role"),
			expectedError: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			if !tt.expectedError || tt.mockError != nil {
				mockAPI.On("UpdateUserRole", tt.userID, tt.expectedRole).Return(tt.mockError)
			}

			client := &PortainerClient{cli: mockAPI}

			err := client.UpdateUserRole(tt.userID, tt.role)

			if tt.expectedError {
				assert.Error(t, err)
				return
			}
			assert.NoError(t, err)
			mockAPI.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/internal/mcp/stack.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/toolgen"
)

func (s *PortainerMCPServer) AddStackFeatures() {
	s.addToolIfExists(ToolListStacks, s.HandleGetStacks())
	s.addToolIfExists(ToolGetStackFile, s.HandleGetStackFile())

	if !s.readOnly {
		s.addToolIfExists(ToolCreateStack, s.HandleCreateStack())
		s.addToolIfExists(ToolUpdateStack, s.HandleUpdateStack())
	}
}

func (s *PortainerMCPServer) HandleGetStacks() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		stacks, err := s.cli.GetStacks()
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get stacks", err), nil
		}

		data, err := json.Marshal(stacks)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to marshal stacks", err), nil
		}

		return mcp.NewToolResultText(string(data)), nil
	}
}

func (s *PortainerMCPServer) HandleGetStackFile() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		stackFile, err := s.cli.GetStackFile(id)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get stack file", err), nil
		}

		return mcp.NewToolResultText(stackFile), nil
	}
}

func (s *PortainerMCPServer) HandleCreateStack() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		name, err := parser.GetString("name", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
		}

		file, err := parser.GetString("file", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid file parameter", err), nil
		}

		environmentGroupIds, err := parser.GetArrayOfIntegers("environmentGroupIds", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid environmentGroupIds parameter", err), nil
		}

		id, err := s.cli.CreateStack(name, file, environmentGroupIds)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("error creating stack", err), nil
		}

		return mcp.NewToolResultText(fmt.Sprintf("Stack created successfully with ID: %d", id)), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateStack() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		file, err := parser.GetString("file", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid file parameter", err), nil
		}

		environmentGroupIds, err := parser.GetArrayOfIntegers("environmentGroupIds", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid environmentGroupIds parameter", err), nil
		}

		err = s.cli.UpdateStack(id, file, environmentGroupIds)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update stack", err), nil
		}

		return mcp.NewToolResultText("Stack updated successfully"), nil
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/settings_test.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"errors"
	"testing"

	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/stretchr/testify/assert"
)

func TestGetSettings(t *testing.T) {
	tests := []struct {
		name          string
		mockSettings  *apimodels.PortainereeSettings
		mockError     error
		expected      models.PortainerSettings
		expectedError bool
	}{
		{
			name: "successful retrieval - internal auth",
			mockSettings: &apimodels.PortainereeSettings{
				AuthenticationMethod:      1, // internal
				EnableEdgeComputeFeatures: true,
				Edge: &apimodels.PortainereeEdge{
					TunnelServerAddress: "tunnel.example.com",
				},
			},
			expected: models.PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: models.AuthenticationMethodInternal,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   true,
					ServerURL: "tunnel.example.com",
				},
			},
		},
		{
			name: "successful retrieval - ldap auth",
			mockSettings: &apimodels.PortainereeSettings{
				AuthenticationMethod:      2, // ldap
				EnableEdgeComputeFeatures: false,
				Edge: &apimodels.PortainereeEdge{
					TunnelServerAddress: "tunnel2.example.com",
				},
			},
			expected: models.PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: models.AuthenticationMethodLDAP,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   false,
					ServerURL: "tunnel2.example.com",
				},
			},
		},
		{
			name: "successful retrieval - oauth auth",
			mockSettings: &apimodels.PortainereeSettings{
				AuthenticationMethod:      3, // oauth
				EnableEdgeComputeFeatures: true,
				Edge: &apimodels.PortainereeEdge{
					TunnelServerAddress: "tunnel3.example.com",
				},
			},
			expected: models.PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: models.AuthenticationMethodOAuth,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   true,
					ServerURL: "tunnel3.example.com",
				},
			},
		},
		{
			name: "successful retrieval - unknown auth",
			mockSettings: &apimodels.PortainereeSettings{
				AuthenticationMethod:      0, // unknown
				EnableEdgeComputeFeatures: false,
				Edge: &apimodels.PortainereeEdge{
					TunnelServerAddress: "tunnel4.example.com",
				},
			},
			expected: models.PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: models.AuthenticationMethodUnknown,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   false,
					ServerURL: "tunnel4.example.com",
				},
			},
		},
		{
			name:          "get settings error",
			mockError:     errors.New("failed to get settings"),
			expectedError: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			mockAPI.On("GetSettings").Return(tt.mockSettings, tt.mockError)

			client := &PortainerClient{cli: mockAPI}

			settings, err := client.GetSettings()

			if tt.expectedError {
				assert.Error(t, err)
				return
			}
			assert.NoError(t, err)
			assert.Equal(t, tt.expected, settings)
			mockAPI.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/tests/integration/user_test.go:
--------------------------------------------------------------------------------

```go
package integration

import (
	"encoding/json"
	"testing"

	mcpmodels "github.com/mark3labs/mcp-go/mcp"
	"github.com/portainer/portainer-mcp/internal/mcp"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/portainer/portainer-mcp/tests/integration/helpers"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const (
	testUsername     = "test-mcp-user"
	testUserPassword = "testpassword"
	userRoleStandard = 2 // Portainer API role ID for Standard User
)

// prepareUserManagementTestEnvironment creates a test user and returns its ID
func prepareUserManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) int {
	testUserID, err := env.RawClient.CreateUser(testUsername, testUserPassword, userRoleStandard)
	require.NoError(t, err, "Failed to create test user via raw client")
	return int(testUserID)
}

// TestUserManagement is an integration test suite that verifies the complete
// lifecycle of user management in Portainer MCP. It tests user listing
// and role updates.
func TestUserManagement(t *testing.T) {
	env := helpers.NewTestEnv(t)
	defer env.Cleanup(t)

	testUserID := prepareUserManagementTestEnvironment(t, env)

	// Subtest: User Listing
	// Verifies listing users (admin + test user) via MCP handler and compares with direct API call.
	t.Run("User Listing", func(t *testing.T) {
		handler := env.MCPServer.HandleGetUsers()
		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
		require.NoError(t, err, "Failed to get users via MCP handler")

		require.Len(t, result.Content, 1, "Expected exactly one content block in the result")
		textContent, ok := result.Content[0].(mcpmodels.TextContent)
		require.True(t, ok, "Expected text content in MCP response")

		var retrievedUsers []models.User
		err = json.Unmarshal([]byte(textContent.Text), &retrievedUsers)
		require.NoError(t, err, "Failed to unmarshal retrieved users")

		require.Equal(t, len(retrievedUsers), 2, "Expected 2 users (admin and test user)")

		rawUsers, err := env.RawClient.ListUsers()
		require.NoError(t, err, "Failed to get users directly via client for comparison")

		expectedConvertedUsers := make([]models.User, 0, len(rawUsers))
		for _, rawUser := range rawUsers {
			expectedConvertedUsers = append(expectedConvertedUsers, models.ConvertToUser(rawUser))
		}

		assert.ElementsMatch(t, expectedConvertedUsers, retrievedUsers, "Mismatch between MCP handler users and converted client users")
	})

	// Subtest: User Role Update
	// Verifies updating the test user's role from standard to admin via the MCP handler.
	t.Run("User Role Update", func(t *testing.T) {
		handler := env.MCPServer.HandleUpdateUserRole()

		newRole := models.UserRoleAdmin
		updateRequest := mcp.CreateMCPRequest(map[string]any{
			"id":   float64(testUserID),
			"role": newRole,
		})

		result, err := handler(env.Ctx, updateRequest)
		require.NoError(t, err, "Failed to update test user role to '%s' via MCP handler", newRole)

		textContent, ok := result.Content[0].(mcpmodels.TextContent)
		require.True(t, ok, "Expected text content in MCP response for role update")
		assert.Contains(t, textContent.Text, "User updated successfully", "Success message mismatch for role update")

		rawUpdatedUser, err := env.RawClient.GetUser(testUserID)
		require.NoError(t, err, "Failed to get test user directly via client after role update")

		convertedUpdatedUser := models.ConvertToUser(rawUpdatedUser)
		assert.Equal(t, newRole, convertedUpdatedUser.Role, "User role was not updated to '%s' after conversion check", newRole)
	})
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/client.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"net/http"

	"github.com/portainer/client-api-go/v2/client"
	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
)

// PortainerAPIClient defines the interface for the underlying Portainer API client
type PortainerAPIClient interface {
	ListEdgeGroups() ([]*apimodels.EdgegroupsDecoratedEdgeGroup, error)
	CreateEdgeGroup(name string, environmentIds []int64) (int64, error)
	UpdateEdgeGroup(id int64, name *string, environmentIds *[]int64, tagIds *[]int64) error
	ListEdgeStacks() ([]*apimodels.PortainereeEdgeStack, error)
	CreateEdgeStack(name string, file string, environmentGroupIds []int64) (int64, error)
	UpdateEdgeStack(id int64, file string, environmentGroupIds []int64) error
	GetEdgeStackFile(id int64) (string, error)
	ListEndpointGroups() ([]*apimodels.PortainerEndpointGroup, error)
	CreateEndpointGroup(name string, associatedEndpoints []int64) (int64, error)
	UpdateEndpointGroup(id int64, name *string, userAccesses *map[int64]string, teamAccesses *map[int64]string) error
	AddEnvironmentToEndpointGroup(groupId int64, environmentId int64) error
	RemoveEnvironmentFromEndpointGroup(groupId int64, environmentId int64) error
	ListEndpoints() ([]*apimodels.PortainereeEndpoint, error)
	GetEndpoint(id int64) (*apimodels.PortainereeEndpoint, error)
	UpdateEndpoint(id int64, tagIds *[]int64, userAccesses *map[int64]string, teamAccesses *map[int64]string) error
	GetSettings() (*apimodels.PortainereeSettings, error)
	ListTags() ([]*apimodels.PortainerTag, error)
	CreateTag(name string) (int64, error)
	ListTeams() ([]*apimodels.PortainerTeam, error)
	ListTeamMemberships() ([]*apimodels.PortainerTeamMembership, error)
	CreateTeam(name string) (int64, error)
	UpdateTeamName(id int, name string) error
	DeleteTeamMembership(id int) error
	CreateTeamMembership(teamId int, userId int) error
	ListUsers() ([]*apimodels.PortainereeUser, error)
	UpdateUserRole(id int, role int64) error
	GetVersion() (string, error)
	ProxyDockerRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error)
	ProxyKubernetesRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error)
}

// PortainerClient is a wrapper around the Portainer SDK client
// that provides simplified access to Portainer API functionality.
type PortainerClient struct {
	cli PortainerAPIClient
}

// ClientOption defines a function that configures a PortainerClient.
type ClientOption func(*clientOptions)

// clientOptions holds configuration options for the PortainerClient.
type clientOptions struct {
	skipTLSVerify bool
}

// WithSkipTLSVerify configures whether to skip TLS certificate verification.
// Setting this to true is not recommended for production environments.
func WithSkipTLSVerify(skip bool) ClientOption {
	return func(o *clientOptions) {
		o.skipTLSVerify = skip
	}
}

// NewPortainerClient creates a new PortainerClient instance with the provided
// server URL and authentication token.
//
// Parameters:
//   - serverURL: The base URL of the Portainer server
//   - token: The authentication token for API access
//   - opts: Optional configuration options for the client
//
// Returns:
//   - A configured PortainerClient ready for API operations
func NewPortainerClient(serverURL string, token string, opts ...ClientOption) *PortainerClient {
	options := clientOptions{
		skipTLSVerify: false, // Default to secure TLS verification
	}

	for _, opt := range opts {
		opt(&options)
	}

	return &PortainerClient{
		cli: client.NewPortainerClient(serverURL, token, client.WithSkipTLSVerify(options.skipTLSVerify)),
	}
}

```

--------------------------------------------------------------------------------
/tests/integration/tag_test.go:
--------------------------------------------------------------------------------

```go
package integration

import (
	"encoding/json"
	"testing"

	mcpmodels "github.com/mark3labs/mcp-go/mcp"
	"github.com/portainer/portainer-mcp/internal/mcp"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/portainer/portainer-mcp/tests/integration/helpers"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const (
	testTagName1 = "test-tag-integration-1"
	testTagName2 = "test-tag-integration-2"
)

// TestTagManagement is an integration test suite that verifies the create
// and list operations for environment tags in Portainer MCP.
func TestTagManagement(t *testing.T) {
	env := helpers.NewTestEnv(t)
	defer env.Cleanup(t)

	// Subtest: Tag Creation
	// Verifies that:
	// - A new tag can be created via the MCP handler.
	// - The handler response indicates success.
	// - The created tag exists in Portainer when checked directly via the Raw Client.
	t.Run("Tag Creation", func(t *testing.T) {
		handler := env.MCPServer.HandleCreateEnvironmentTag()
		request := mcp.CreateMCPRequest(map[string]any{
			"name": testTagName1,
		})

		result, err := handler(env.Ctx, request)
		require.NoError(t, err, "Failed to create tag via MCP handler")

		textContent, ok := result.Content[0].(mcpmodels.TextContent)
		require.True(t, ok, "Expected text content in MCP response")
		// Just check for the success prefix, no need to parse ID here
		assert.Contains(t, textContent.Text, "Environment tag created successfully with ID:", "Success message prefix mismatch")

		// Verify by fetching the tag directly via the client and finding the created tag by name
		tag, err := env.RawClient.GetTagByName(testTagName1)
		require.NoError(t, err, "Failed to get tag directly via client after creation")
		assert.Equal(t, testTagName1, tag.Name, "Tag name mismatch")
	})

	// Subtest: Tag Listing
	// Verifies that:
	// - Tags can be listed via the MCP handler.
	// - The list includes previously created tags.
	// - The data structure returned by the handler matches the expected local model.
	// - Compares MCP handler output with direct client API call result after conversion.
	t.Run("Tag Listing", func(t *testing.T) {
		// Create another tag directly for listing comparison
		_, err := env.RawClient.CreateTag(testTagName2)
		require.NoError(t, err, "Failed to create second tag directly")

		handler := env.MCPServer.HandleGetEnvironmentTags()
		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
		require.NoError(t, err, "Failed to get tags via MCP handler")

		require.Len(t, result.Content, 1, "Expected exactly one content block in the result")
		textContent, ok := result.Content[0].(mcpmodels.TextContent)
		require.True(t, ok, "Expected text content in MCP response")

		// Unmarshal the result from the MCP handler
		var retrievedTags []models.EnvironmentTag
		err = json.Unmarshal([]byte(textContent.Text), &retrievedTags)
		require.NoError(t, err, "Failed to unmarshal retrieved tags")

		// Fetch tags directly via client
		rawTags, err := env.RawClient.ListTags()
		require.NoError(t, err, "Failed to get tags directly via client for comparison")

		// Convert the raw tags to the expected EnvironmentTag model
		expectedConvertedTags := make([]models.EnvironmentTag, 0, len(rawTags))
		for _, rawTag := range rawTags {
			expectedConvertedTags = append(expectedConvertedTags, models.ConvertTagToEnvironmentTag(rawTag))
		}

		// Compare the tags from MCP handler with the ones converted from the direct client call
		// Use ElementsMatch as the order might not be guaranteed.
		assert.ElementsMatch(t, expectedConvertedTags, retrievedTags, "Mismatch between MCP handler tags and converted client tags")
	})
}

```

--------------------------------------------------------------------------------
/internal/mcp/environment.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/toolgen"
)

func (s *PortainerMCPServer) AddEnvironmentFeatures() {
	s.addToolIfExists(ToolListEnvironments, s.HandleGetEnvironments())

	if !s.readOnly {
		s.addToolIfExists(ToolUpdateEnvironmentTags, s.HandleUpdateEnvironmentTags())
		s.addToolIfExists(ToolUpdateEnvironmentUserAccesses, s.HandleUpdateEnvironmentUserAccesses())
		s.addToolIfExists(ToolUpdateEnvironmentTeamAccesses, s.HandleUpdateEnvironmentTeamAccesses())
	}
}

func (s *PortainerMCPServer) HandleGetEnvironments() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		environments, err := s.cli.GetEnvironments()
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get environments", err), nil
		}

		data, err := json.Marshal(environments)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to marshal environments", err), nil
		}

		return mcp.NewToolResultText(string(data)), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateEnvironmentTags() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		tagIds, err := parser.GetArrayOfIntegers("tagIds", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid tagIds parameter", err), nil
		}

		err = s.cli.UpdateEnvironmentTags(id, tagIds)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update environment tags", err), nil
		}

		return mcp.NewToolResultText("Environment tags updated successfully"), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateEnvironmentUserAccesses() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		userAccesses, err := parser.GetArrayOfObjects("userAccesses", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid userAccesses parameter", err), nil
		}

		userAccessesMap, err := parseAccessMap(userAccesses)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid user accesses", err), nil
		}

		err = s.cli.UpdateEnvironmentUserAccesses(id, userAccessesMap)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update environment user accesses", err), nil
		}

		return mcp.NewToolResultText("Environment user accesses updated successfully"), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateEnvironmentTeamAccesses() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		teamAccesses, err := parser.GetArrayOfObjects("teamAccesses", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid teamAccesses parameter", err), nil
		}

		teamAccessesMap, err := parseAccessMap(teamAccesses)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid team accesses", err), nil
		}

		err = s.cli.UpdateEnvironmentTeamAccesses(id, teamAccessesMap)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update environment team accesses", err), nil
		}

		return mcp.NewToolResultText("Environment team accesses updated successfully"), nil
	}
}

```

--------------------------------------------------------------------------------
/internal/mcp/schema.go:
--------------------------------------------------------------------------------

```go
package mcp

import "slices"

// Tool names as defined in the YAML file
const (
	ToolCreateEnvironmentGroup             = "createEnvironmentGroup"
	ToolListEnvironmentGroups              = "listEnvironmentGroups"
	ToolUpdateEnvironmentGroup             = "updateEnvironmentGroup"
	ToolCreateAccessGroup                  = "createAccessGroup"
	ToolListAccessGroups                   = "listAccessGroups"
	ToolUpdateAccessGroup                  = "updateAccessGroup"
	ToolAddEnvironmentToAccessGroup        = "addEnvironmentToAccessGroup"
	ToolRemoveEnvironmentFromAccessGroup   = "removeEnvironmentFromAccessGroup"
	ToolListEnvironments                   = "listEnvironments"
	ToolUpdateEnvironment                  = "updateEnvironment"
	ToolGetStackFile                       = "getStackFile"
	ToolCreateStack                        = "createStack"
	ToolListStacks                         = "listStacks"
	ToolUpdateStack                        = "updateStack"
	ToolCreateEnvironmentTag               = "createEnvironmentTag"
	ToolListEnvironmentTags                = "listEnvironmentTags"
	ToolCreateTeam                         = "createTeam"
	ToolListTeams                          = "listTeams"
	ToolUpdateTeamName                     = "updateTeamName"
	ToolUpdateTeamMembers                  = "updateTeamMembers"
	ToolListUsers                          = "listUsers"
	ToolUpdateUserRole                     = "updateUserRole"
	ToolGetSettings                        = "getSettings"
	ToolUpdateAccessGroupName              = "updateAccessGroupName"
	ToolUpdateAccessGroupUserAccesses      = "updateAccessGroupUserAccesses"
	ToolUpdateAccessGroupTeamAccesses      = "updateAccessGroupTeamAccesses"
	ToolUpdateEnvironmentTags              = "updateEnvironmentTags"
	ToolUpdateEnvironmentUserAccesses      = "updateEnvironmentUserAccesses"
	ToolUpdateEnvironmentTeamAccesses      = "updateEnvironmentTeamAccesses"
	ToolUpdateEnvironmentGroupName         = "updateEnvironmentGroupName"
	ToolUpdateEnvironmentGroupEnvironments = "updateEnvironmentGroupEnvironments"
	ToolUpdateEnvironmentGroupTags         = "updateEnvironmentGroupTags"
	ToolDockerProxy                        = "dockerProxy"
	ToolKubernetesProxy                    = "kubernetesProxy"
	ToolKubernetesProxyStripped            = "getKubernetesResourceStripped"
)

// Access levels for users and teams
const (
	// AccessLevelEnvironmentAdmin represents the environment administrator access level
	AccessLevelEnvironmentAdmin = "environment_administrator"
	// AccessLevelHelpdeskUser represents the helpdesk user access level
	AccessLevelHelpdeskUser = "helpdesk_user"
	// AccessLevelStandardUser represents the standard user access level
	AccessLevelStandardUser = "standard_user"
	// AccessLevelReadonlyUser represents the readonly user access level
	AccessLevelReadonlyUser = "readonly_user"
	// AccessLevelOperatorUser represents the operator user access level
	AccessLevelOperatorUser = "operator_user"
)

// User roles
const (
	// UserRoleAdmin represents an admin user role
	UserRoleAdmin = "admin"
	// UserRoleUser represents a regular user role
	UserRoleUser = "user"
	// UserRoleEdgeAdmin represents an edge admin user role
	UserRoleEdgeAdmin = "edge_admin"
)

// All available access levels
var AllAccessLevels = []string{
	AccessLevelEnvironmentAdmin,
	AccessLevelHelpdeskUser,
	AccessLevelStandardUser,
	AccessLevelReadonlyUser,
	AccessLevelOperatorUser,
}

// All available user roles
var AllUserRoles = []string{
	UserRoleAdmin,
	UserRoleUser,
	UserRoleEdgeAdmin,
}

// isValidAccessLevel checks if a given string is a valid access level
func isValidAccessLevel(access string) bool {
	return slices.Contains(AllAccessLevels, access)
}

// isValidUserRole checks if a given string is a valid user role
func isValidUserRole(role string) bool {
	return slices.Contains(AllUserRoles, role)
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/kubernetes_test.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"strings"
	"testing"

	"github.com/portainer/client-api-go/v2/client"
	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/stretchr/testify/assert"
)

func TestProxyKubernetesRequest(t *testing.T) {
	tests := []struct {
		name             string
		opts             models.KubernetesProxyRequestOptions
		mockResponse     *http.Response
		mockError        error
		expectedError    bool
		expectedStatus   int
		expectedRespBody string
	}{
		{
			name: "GET request with query parameters",
			opts: models.KubernetesProxyRequestOptions{
				EnvironmentID: 1,
				Method:        "GET",
				Path:          "/api/v1/pods",
				QueryParams:   map[string]string{"namespace": "default", "labelSelector": "app=myapp"},
			},
			mockResponse: &http.Response{
				StatusCode: http.StatusOK,
				Body:       io.NopCloser(strings.NewReader(`{"items": [{"metadata": {"name": "pod1"}}]}`)),
			},
			mockError:        nil,
			expectedError:    false,
			expectedStatus:   http.StatusOK,
			expectedRespBody: `{"items": [{"metadata": {"name": "pod1"}}]}`,
		},
		{
			name: "POST request with custom headers and body",
			opts: models.KubernetesProxyRequestOptions{
				EnvironmentID: 2,
				Method:        "POST",
				Path:          "/api/v1/namespaces/default/services",
				Headers:       map[string]string{"X-Custom-Header": "value1", "Content-Type": "application/json"},
				Body:          bytes.NewBufferString(`{"apiVersion": "v1", "kind": "Service", "metadata": {"name": "my-service"}}`),
			},
			mockResponse: &http.Response{
				StatusCode: http.StatusCreated,
				Body:       io.NopCloser(strings.NewReader(`{"metadata": {"name": "my-service"}}`)),
			},
			mockError:        nil,
			expectedError:    false,
			expectedStatus:   http.StatusCreated,
			expectedRespBody: `{"metadata": {"name": "my-service"}}`,
		},
		{
			name: "API error",
			opts: models.KubernetesProxyRequestOptions{
				EnvironmentID: 3,
				Method:        "GET",
				Path:          "/version",
			},
			mockResponse:     nil,
			mockError:        errors.New("failed to proxy kubernetes request"),
			expectedError:    true,
			expectedStatus:   0,  // Not applicable
			expectedRespBody: "", // Not applicable
		},
		{
			name: "Request with no params, headers, or body",
			opts: models.KubernetesProxyRequestOptions{
				EnvironmentID: 4,
				Method:        "GET",
				Path:          "/healthz",
			},
			mockResponse: &http.Response{
				StatusCode: http.StatusOK,
				Body:       io.NopCloser(strings.NewReader("ok")),
			},
			mockError:        nil,
			expectedError:    false,
			expectedStatus:   http.StatusOK,
			expectedRespBody: "ok",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockAPI := new(MockPortainerAPI)
			proxyOpts := client.ProxyRequestOptions{
				Method:      tt.opts.Method,
				APIPath:     tt.opts.Path,
				QueryParams: tt.opts.QueryParams,
				Headers:     tt.opts.Headers,
				Body:        tt.opts.Body,
			}
			mockAPI.On("ProxyKubernetesRequest", tt.opts.EnvironmentID, proxyOpts).Return(tt.mockResponse, tt.mockError)

			portainerClient := &PortainerClient{cli: mockAPI}

			resp, err := portainerClient.ProxyKubernetesRequest(tt.opts)

			if tt.expectedError {
				assert.Error(t, err)
				assert.EqualError(t, err, tt.mockError.Error())
				assert.Nil(t, resp)
			} else {
				assert.NoError(t, err)
				assert.NotNil(t, resp)
				assert.Equal(t, tt.expectedStatus, resp.StatusCode)

				// Read and verify the response body
				if assert.NotNil(t, resp.Body) { // Ensure body is not nil before reading
					defer resp.Body.Close()
					bodyBytes, readErr := io.ReadAll(resp.Body)
					assert.NoError(t, readErr)
					assert.Equal(t, tt.expectedRespBody, string(bodyBytes))
				} else if tt.expectedRespBody != "" {
					assert.Fail(t, "Expected a response body but got nil")
				}
			}

			mockAPI.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/client/access_group.go:
--------------------------------------------------------------------------------

```go
package client

import (
	"fmt"

	"github.com/portainer/portainer-mcp/pkg/portainer/models"
	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
)

// GetAccessGroups retrieves all access groups from the Portainer server.
// Access groups are the equivalent of Endpoint Groups in Portainer.
//
// Returns:
//   - A slice of AccessGroup objects
//   - An error if the operation fails
func (c *PortainerClient) GetAccessGroups() ([]models.AccessGroup, error) {
	groups, err := c.cli.ListEndpointGroups()
	if err != nil {
		return nil, err
	}

	endpoints, err := c.cli.ListEndpoints()
	if err != nil {
		return nil, err
	}

	accessGroups := make([]models.AccessGroup, len(groups))
	for i, group := range groups {
		accessGroups[i] = models.ConvertEndpointGroupToAccessGroup(group, endpoints)
	}

	return accessGroups, nil
}

// CreateAccessGroup creates a new access group in Portainer.
//
// Parameters:
//   - name: The name of the access group
//   - environmentIds: The IDs of the environments that are part of the access group
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) CreateAccessGroup(name string, environmentIds []int) (int, error) {
	groupID, err := c.cli.CreateEndpointGroup(name, utils.IntToInt64Slice(environmentIds))
	if err != nil {
		return 0, fmt.Errorf("failed to create access group: %w", err)
	}

	return int(groupID), nil
}

// UpdateAccessGroupName updates the name of an existing access group in Portainer.
//
// Parameters:
//   - id: The ID of the access group
//   - name: The new name for the access group
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateAccessGroupName(id int, name string) error {
	err := c.cli.UpdateEndpointGroup(int64(id), &name, nil, nil)
	if err != nil {
		return fmt.Errorf("failed to update access group name: %w", err)
	}
	return nil
}

// UpdateAccessGroupUserAccesses updates the user access policies of an existing access group in Portainer.
//
// Parameters:
//   - id: The ID of the access group
//   - userAccesses: Map of user IDs to their access level
//
// Valid access levels are:
//   - environment_administrator
//   - helpdesk_user
//   - standard_user
//   - readonly_user
//   - operator_user
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error {
	uac := utils.IntToInt64Map(userAccesses)
	err := c.cli.UpdateEndpointGroup(int64(id), nil, &uac, nil)
	if err != nil {
		return fmt.Errorf("failed to update access group user accesses: %w", err)
	}
	return nil
}

// UpdateAccessGroupTeamAccesses updates the team access policies of an existing access group in Portainer.
//
// Parameters:
//   - id: The ID of the access group
//   - teamAccesses: Map of team IDs to their access level
//
// Valid access levels are:
//   - environment_administrator
//   - helpdesk_user
//   - standard_user
//   - readonly_user
//   - operator_user
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error {
	tac := utils.IntToInt64Map(teamAccesses)
	err := c.cli.UpdateEndpointGroup(int64(id), nil, nil, &tac)
	if err != nil {
		return fmt.Errorf("failed to update access group team accesses: %w", err)
	}
	return nil
}

// AddEnvironmentToAccessGroup adds an environment to an access group
//
// Parameters:
//   - id: The ID of the access group
//   - environmentId: The ID of the environment to add to the access group
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) AddEnvironmentToAccessGroup(id int, environmentId int) error {
	return c.cli.AddEnvironmentToEndpointGroup(int64(id), int64(environmentId))
}

// RemoveEnvironmentFromAccessGroup removes an environment from an access group
//
// Parameters:
//   - id: The ID of the access group
//   - environmentId: The ID of the environment to remove from the access group
//
// Returns:
//   - An error if the operation fails
func (c *PortainerClient) RemoveEnvironmentFromAccessGroup(id int, environmentId int) error {
	return c.cli.RemoveEnvironmentFromEndpointGroup(int64(id), int64(environmentId))
}

```

--------------------------------------------------------------------------------
/internal/mcp/tag_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 TestHandleGetEnvironmentTags(t *testing.T) {
	tests := []struct {
		name         string
		mockTags     []models.EnvironmentTag
		mockError    error
		expectError  bool
		expectedJSON string
	}{
		{
			name: "successful tags retrieval",
			mockTags: []models.EnvironmentTag{
				{ID: 1, Name: "tag1"},
				{ID: 2, Name: "tag2"},
			},
			mockError:   nil,
			expectError: false,
		},
		{
			name:        "api error",
			mockTags:    nil,
			mockError:   fmt.Errorf("api error"),
			expectError: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Create mock client
			mockClient := &MockPortainerClient{}
			mockClient.On("GetEnvironmentTags").Return(tt.mockTags, tt.mockError)

			// Create server with mock client
			server := &PortainerMCPServer{
				cli: mockClient,
			}

			// Call handler
			handler := server.HandleGetEnvironmentTags()
			result, err := handler(context.Background(), mcp.CallToolRequest{})

			// Verify results
			if tt.expectError {
				assert.NoError(t, err)
				assert.NotNil(t, result)
				assert.True(t, result.IsError, "result.IsError should be true for API errors")
				assert.Len(t, result.Content, 1)
				textContent, ok := result.Content[0].(mcp.TextContent)
				assert.True(t, ok, "Result content should be mcp.TextContent")
				if tt.mockError != nil {
					assert.Contains(t, textContent.Text, tt.mockError.Error())
				}
			} else {
				assert.NoError(t, err)

				// Verify JSON response
				assert.Len(t, result.Content, 1)
				textContent, ok := result.Content[0].(mcp.TextContent)
				assert.True(t, ok)

				var tags []models.EnvironmentTag
				err = json.Unmarshal([]byte(textContent.Text), &tags)
				assert.NoError(t, err)
				assert.Equal(t, tt.mockTags, tags)
			}

			// Verify mock expectations
			mockClient.AssertExpectations(t)
		})
	}
}

func TestHandleCreateEnvironmentTag(t *testing.T) {
	tests := []struct {
		name        string
		inputName   string
		mockID      int
		mockError   error
		expectError bool
	}{
		{
			name:        "successful tag creation",
			inputName:   "test-tag",
			mockID:      123,
			mockError:   nil,
			expectError: false,
		},
		{
			name:        "api error",
			inputName:   "test-tag",
			mockID:      0,
			mockError:   fmt.Errorf("api error"),
			expectError: true,
		},
		{
			name:        "missing name parameter",
			inputName:   "",
			mockID:      0,
			mockError:   nil,
			expectError: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Create mock client
			mockClient := &MockPortainerClient{}
			if tt.inputName != "" {
				mockClient.On("CreateEnvironmentTag", tt.inputName).Return(tt.mockID, tt.mockError)
			}

			// Create server with mock client
			server := &PortainerMCPServer{
				cli: mockClient,
			}

			// Create request with parameters
			request := CreateMCPRequest(map[string]any{})
			if tt.inputName != "" {
				request.Params.Arguments = map[string]any{
					"name": tt.inputName,
				}
			}

			// Call handler
			handler := server.HandleCreateEnvironmentTag()
			result, err := handler(context.Background(), request)

			// Verify results
			if tt.expectError {
				assert.NoError(t, err)
				assert.NotNil(t, result)
				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
				assert.Len(t, result.Content, 1)
				textContent, ok := result.Content[0].(mcp.TextContent)
				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
				if tt.mockError != nil {
					assert.Contains(t, textContent.Text, tt.mockError.Error())
				} else {
					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
					if tt.inputName == "" {
						assert.Contains(t, textContent.Text, "name")
					}
				}
			} 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))
			}

			// Verify mock expectations
			mockClient.AssertExpectations(t)
		})
	}
}

```

--------------------------------------------------------------------------------
/pkg/portainer/models/settings_test.go:
--------------------------------------------------------------------------------

```go
package models

import (
	"testing"

	"github.com/portainer/client-api-go/v2/pkg/models"
	"github.com/stretchr/testify/assert"
)

func TestConvertAuthenticationMethod(t *testing.T) {
	tests := []struct {
		name           string
		methodID       int64
		expectedMethod string
	}{
		{
			name:           "Internal authentication",
			methodID:       1,
			expectedMethod: AuthenticationMethodInternal,
		},
		{
			name:           "LDAP authentication",
			methodID:       2,
			expectedMethod: AuthenticationMethodLDAP,
		},
		{
			name:           "OAuth authentication",
			methodID:       3,
			expectedMethod: AuthenticationMethodOAuth,
		},
		{
			name:           "Unknown authentication (0)",
			methodID:       0,
			expectedMethod: AuthenticationMethodUnknown,
		},
		{
			name:           "Unknown authentication (negative)",
			methodID:       -1,
			expectedMethod: AuthenticationMethodUnknown,
		},
		{
			name:           "Unknown authentication (large value)",
			methodID:       999,
			expectedMethod: AuthenticationMethodUnknown,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := convertAuthenticationMethod(tt.methodID)
			assert.Equal(t, tt.expectedMethod, result)
		})
	}
}

func TestConvertSettingsToPortainerSettings(t *testing.T) {
	tests := []struct {
		name           string
		input          *models.PortainereeSettings
		expectedOutput PortainerSettings
		shouldPanic    bool
	}{
		{
			name: "Complete settings conversion",
			input: &models.PortainereeSettings{
				AuthenticationMethod:      1,
				EnableEdgeComputeFeatures: true,
				Edge: &models.PortainereeEdge{
					TunnelServerAddress: "https://edge.example.com",
				},
			},
			expectedOutput: PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: AuthenticationMethodInternal,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   true,
					ServerURL: "https://edge.example.com",
				},
			},
		},
		{
			name: "Settings with LDAP authentication",
			input: &models.PortainereeSettings{
				AuthenticationMethod:      2,
				EnableEdgeComputeFeatures: false,
				Edge: &models.PortainereeEdge{
					TunnelServerAddress: "",
				},
			},
			expectedOutput: PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: AuthenticationMethodLDAP,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   false,
					ServerURL: "",
				},
			},
		},
		{
			name: "Settings with OAuth authentication",
			input: &models.PortainereeSettings{
				AuthenticationMethod:      3,
				EnableEdgeComputeFeatures: true,
				Edge: &models.PortainereeEdge{
					TunnelServerAddress: "https://tunnel.portainer.io",
				},
			},
			expectedOutput: PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: AuthenticationMethodOAuth,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   true,
					ServerURL: "https://tunnel.portainer.io",
				},
			},
		},
		{
			name: "Settings with unknown authentication",
			input: &models.PortainereeSettings{
				AuthenticationMethod:      99,
				EnableEdgeComputeFeatures: false,
				Edge: &models.PortainereeEdge{
					TunnelServerAddress: "",
				},
			},
			expectedOutput: PortainerSettings{
				Authentication: struct {
					Method string `json:"method"`
				}{
					Method: AuthenticationMethodUnknown,
				},
				Edge: struct {
					Enabled   bool   `json:"enabled"`
					ServerURL string `json:"server_url"`
				}{
					Enabled:   false,
					ServerURL: "",
				},
			},
		},
		{
			name:        "Nil input",
			input:       nil,
			shouldPanic: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if tt.shouldPanic {
				assert.Panics(t, func() {
					ConvertSettingsToPortainerSettings(tt.input)
				})
				return
			}

			result := ConvertSettingsToPortainerSettings(tt.input)
			assert.Equal(t, tt.expectedOutput.Authentication.Method, result.Authentication.Method)
			assert.Equal(t, tt.expectedOutput.Edge.Enabled, result.Edge.Enabled)
			assert.Equal(t, tt.expectedOutput.Edge.ServerURL, result.Edge.ServerURL)
		})
	}
}

```

--------------------------------------------------------------------------------
/internal/mcp/group.go:
--------------------------------------------------------------------------------

```go
package mcp

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/portainer/portainer-mcp/pkg/toolgen"
)

func (s *PortainerMCPServer) AddEnvironmentGroupFeatures() {
	s.addToolIfExists(ToolListEnvironmentGroups, s.HandleGetEnvironmentGroups())

	if !s.readOnly {
		s.addToolIfExists(ToolCreateEnvironmentGroup, s.HandleCreateEnvironmentGroup())
		s.addToolIfExists(ToolUpdateEnvironmentGroupName, s.HandleUpdateEnvironmentGroupName())
		s.addToolIfExists(ToolUpdateEnvironmentGroupEnvironments, s.HandleUpdateEnvironmentGroupEnvironments())
		s.addToolIfExists(ToolUpdateEnvironmentGroupTags, s.HandleUpdateEnvironmentGroupTags())
	}
}

func (s *PortainerMCPServer) HandleGetEnvironmentGroups() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		edgeGroups, err := s.cli.GetEnvironmentGroups()
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to get environment groups", err), nil
		}

		data, err := json.Marshal(edgeGroups)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to marshal environment groups", err), nil
		}

		return mcp.NewToolResultText(string(data)), nil
	}
}

func (s *PortainerMCPServer) HandleCreateEnvironmentGroup() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		name, err := parser.GetString("name", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
		}

		environmentIds, err := parser.GetArrayOfIntegers("environmentIds", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
		}

		id, err := s.cli.CreateEnvironmentGroup(name, environmentIds)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to create environment group", err), nil
		}

		return mcp.NewToolResultText(fmt.Sprintf("Environment group created successfully with ID: %d", id)), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupName() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		name, err := parser.GetString("name", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
		}

		err = s.cli.UpdateEnvironmentGroupName(id, name)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update environment group name", err), nil
		}

		return mcp.NewToolResultText("Environment group name updated successfully"), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupEnvironments() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		environmentIds, err := parser.GetArrayOfIntegers("environmentIds", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
		}

		err = s.cli.UpdateEnvironmentGroupEnvironments(id, environmentIds)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update environment group environments", err), nil
		}

		return mcp.NewToolResultText("Environment group environments updated successfully"), nil
	}
}

func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupTags() server.ToolHandlerFunc {
	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		parser := toolgen.NewParameterParser(request)

		id, err := parser.GetInt("id", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
		}

		tagIds, err := parser.GetArrayOfIntegers("tagIds", true)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("invalid tagIds parameter", err), nil
		}

		err = s.cli.UpdateEnvironmentGroupTags(id, tagIds)
		if err != nil {
			return mcp.NewToolResultErrorFromErr("failed to update environment group tags", err), nil
		}

		return mcp.NewToolResultText("Environment group tags updated successfully"), nil
	}
}

```
Page 1/4FirstPrevNextLast