#
tokens: 49559/50000 67/115 files (page 1/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 1 of 5. Use http://codebase.md/portainer/portainer-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .cursor
│   └── rules
│       └── integration-test.mdc
├── .github
│   └── workflows
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── CLAUDE.md
├── cloc.sh
├── cmd
│   ├── portainer-mcp
│   │   └── mcp.go
│   └── token-count
│       └── token.go
├── docs
│   ├── clients_and_models.md
│   ├── design
│   │   ├── 202503-1-external-tools-file.md
│   │   ├── 202503-2-tools-vs-mcp-resources.md
│   │   ├── 202503-3-specific-update-tools.md
│   │   ├── 202504-1-embedded-tools-yaml.md
│   │   ├── 202504-2-tools-yaml-versioning.md
│   │   ├── 202504-3-portainer-version-compatibility.md
│   │   └── 202504-4-read-only-mode.md
│   └── design_summary.md
├── go.mod
├── go.sum
├── internal
│   ├── k8sutil
│   │   ├── stripper_test.go
│   │   └── stripper.go
│   ├── mcp
│   │   ├── access_group_test.go
│   │   ├── access_group.go
│   │   ├── docker_test.go
│   │   ├── docker.go
│   │   ├── environment_test.go
│   │   ├── environment.go
│   │   ├── group_test.go
│   │   ├── group.go
│   │   ├── kubernetes_test.go
│   │   ├── kubernetes.go
│   │   ├── mocks_test.go
│   │   ├── schema_test.go
│   │   ├── schema.go
│   │   ├── server_test.go
│   │   ├── server.go
│   │   ├── settings_test.go
│   │   ├── settings.go
│   │   ├── stack_test.go
│   │   ├── stack.go
│   │   ├── tag_test.go
│   │   ├── tag.go
│   │   ├── team_test.go
│   │   ├── team.go
│   │   ├── testdata
│   │   │   ├── invalid_tools.yaml
│   │   │   └── valid_tools.yaml
│   │   ├── user_test.go
│   │   ├── user.go
│   │   ├── utils_test.go
│   │   └── utils.go
│   └── tooldef
│       ├── tooldef_test.go
│       ├── tooldef.go
│       └── tools.yaml
├── LICENSE
├── Makefile
├── pkg
│   ├── portainer
│   │   ├── client
│   │   │   ├── access_group_test.go
│   │   │   ├── access_group.go
│   │   │   ├── client_test.go
│   │   │   ├── client.go
│   │   │   ├── docker_test.go
│   │   │   ├── docker.go
│   │   │   ├── environment_test.go
│   │   │   ├── environment.go
│   │   │   ├── group_test.go
│   │   │   ├── group.go
│   │   │   ├── kubernetes_test.go
│   │   │   ├── kubernetes.go
│   │   │   ├── mocks_test.go
│   │   │   ├── settings_test.go
│   │   │   ├── settings.go
│   │   │   ├── stack_test.go
│   │   │   ├── stack.go
│   │   │   ├── tag_test.go
│   │   │   ├── tag.go
│   │   │   ├── team_test.go
│   │   │   ├── team.go
│   │   │   ├── user_test.go
│   │   │   ├── user.go
│   │   │   ├── version_test.go
│   │   │   └── version.go
│   │   ├── models
│   │   │   ├── access_group_test.go
│   │   │   ├── access_group.go
│   │   │   ├── access_policy_test.go
│   │   │   ├── access_policy.go
│   │   │   ├── docker.go
│   │   │   ├── environment_test.go
│   │   │   ├── environment.go
│   │   │   ├── group_test.go
│   │   │   ├── group.go
│   │   │   ├── kubernetes.go
│   │   │   ├── settings_test.go
│   │   │   ├── settings.go
│   │   │   ├── stack_test.go
│   │   │   ├── stack.go
│   │   │   ├── tag_test.go
│   │   │   ├── tag.go
│   │   │   ├── team_test.go
│   │   │   ├── team.go
│   │   │   ├── user_test.go
│   │   │   └── user.go
│   │   └── utils
│   │       ├── utils_test.go
│   │       └── utils.go
│   └── toolgen
│       ├── param_test.go
│       ├── param.go
│       ├── yaml_test.go
│       └── yaml.go
├── README.md
├── tests
│   └── integration
│       ├── access_group_test.go
│       ├── containers
│       │   └── portainer.go
│       ├── docker_test.go
│       ├── environment_test.go
│       ├── group_test.go
│       ├── helpers
│       │   └── test_env.go
│       ├── server_test.go
│       ├── settings_test.go
│       ├── stack_test.go
│       ├── tag_test.go
│       ├── team_test.go
│       └── user_test.go
└── token.sh
```

# Files

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

```
1 | dist
2 | .dev
3 | .tmp
```

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

```markdown
  1 | # Portainer MCP
  2 | [![Go Report Card](https://goreportcard.com/badge/github.com/portainer/portainer-mcp)](https://goreportcard.com/report/github.com/portainer/portainer-mcp)
  3 | ![coverage](https://raw.githubusercontent.com/portainer/portainer-mcp/badges/.badges/main/coverage.svg)
  4 | 
  5 | Ever wished you could just ask Portainer what's going on?
  6 | 
  7 | 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.
  8 | 
  9 | ![portainer-mcp-demo](https://downloads.portainer.io/mcp-demo5.gif)
 10 | 
 11 | ## Overview
 12 | 
 13 | 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.
 14 | 
 15 | 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.
 16 | 
 17 | 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.
 18 | 
 19 | > [!NOTE]
 20 | > 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.
 21 | 
 22 | See the [Supported Capabilities](#supported-capabilities) sections for more details on compatibility and available features.
 23 | 
 24 | *Note: This project is currently under development.*
 25 | 
 26 | It is currently designed to work with a Portainer administrator API token.
 27 | 
 28 | ## Installation
 29 | 
 30 | 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.
 31 | 
 32 | **Download the archive:**
 33 | 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`:
 34 | 
 35 | ```bash
 36 | # Example for macOS (ARM64) - adjust version and architecture as needed
 37 | 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
 38 | ```
 39 | 
 40 | (Linux AMD64 binaries are also available on the release page.)
 41 | 
 42 | **(Optional but recommended) Verify the checksum:**
 43 | First, download the corresponding `.md5` checksum file from the release page.
 44 | Example for macOS (ARM64) `v0.2.0`:
 45 | 
 46 | ```bash
 47 | # Download the checksum file (adjust version/arch)
 48 | 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
 49 | # Now verify (output should match the content of the .md5 file)
 50 | 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
 51 | ```
 52 | 
 53 | (For Linux, you can use `md5sum -c <checksum_file_name>.md5`)
 54 | If the verification command outputs "OK", the file is intact.
 55 | 
 56 | **Extract the archive:**
 57 | 
 58 | ```bash
 59 | # Adjust the filename based on the downloaded version/OS/architecture
 60 | tar -xzf portainer-mcp-v0.2.0-darwin-arm64.tar.gz
 61 | ```
 62 | 
 63 | This will extract the `portainer-mcp` executable.
 64 | 
 65 | **Move the executable:**
 66 | Move the executable to a location in your `$PATH` (e.g., `/usr/local/bin`) or note its location for the configuration step below.
 67 | 
 68 | # Usage
 69 | 
 70 | With Claude Desktop, configure it like so:
 71 | 
 72 | ```
 73 | {
 74 |     "mcpServers": {
 75 |         "portainer": {
 76 |             "command": "/path/to/portainer-mcp",
 77 |             "args": [
 78 |                 "-server",
 79 |                 "[IP]:[PORT]",
 80 |                 "-token",
 81 |                 "[TOKEN]",
 82 |                 "-tools",
 83 |                 "/tmp/tools.yaml"
 84 |             ]
 85 |         }
 86 |     }
 87 | }
 88 | ```
 89 | 
 90 | Replace `[IP]`, `[PORT]` and `[TOKEN]` with the IP, port and API access token associated with your Portainer instance.
 91 | 
 92 | > [!NOTE]
 93 | > 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.
 94 | 
 95 | ## Disable Version Check
 96 | 
 97 | 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.
 98 | 
 99 | To disable the version check, add the `-disable-version-check` flag to your command arguments:
100 | 
101 | ```
102 | {
103 |     "mcpServers": {
104 |         "portainer": {
105 |             "command": "/path/to/portainer-mcp",
106 |             "args": [
107 |                 "-server",
108 |                 "[IP]:[PORT]",
109 |                 "-token",
110 |                 "[TOKEN]",
111 |                 "-disable-version-check"
112 |             ]
113 |         }
114 |     }
115 | }
116 | ```
117 | 
118 | > [!WARNING]
119 | > 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.
120 | 
121 | When using this flag:
122 | - The application will skip Portainer server version validation at startup
123 | - Some features may not work correctly due to API differences between versions
124 | - Newer Portainer versions may have API changes that cause errors
125 | - Older Portainer versions may be missing APIs that the tool expects
126 | 
127 | This flag is useful when:
128 | - You're running a newer Portainer version that doesn't have MCP support yet
129 | - You're running an older Portainer version and want to try the tool anyway
130 | 
131 | ## Tool Customization
132 | 
133 | 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.
134 | 
135 | You can customize the tool definitions by specifying a custom tools file path using the `-tools` flag:
136 | 
137 | ```
138 | {
139 |     "mcpServers": {
140 |         "portainer": {
141 |             "command": "/path/to/portainer-mcp",
142 |             "args": [
143 |                 "-server",
144 |                 "[IP]:[PORT]",
145 |                 "-token",
146 |                 "[TOKEN]",
147 |                 "-tools",
148 |                 "/path/to/custom/tools.yaml"
149 |             ]
150 |         }
151 |     }
152 | }
153 | ```
154 | 
155 | 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.
156 | 
157 | > [!WARNING]
158 | > 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.
159 | 
160 | ## Read-Only Mode
161 | 
162 | 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.
163 | 
164 | To enable read-only mode, add the `-read-only` flag to your command arguments:
165 | 
166 | ```
167 | {
168 |     "mcpServers": {
169 |         "portainer": {
170 |             "command": "/path/to/portainer-mcp",
171 |             "args": [
172 |                 "-server",
173 |                 "[IP]:[PORT]",
174 |                 "-token",
175 |                 "[TOKEN]",
176 |                 "-read-only"
177 |             ]
178 |         }
179 |     }
180 | }
181 | ```
182 | 
183 | When using read-only mode:
184 | - Only read tools (list, get) will be available to the AI model
185 | - All write tools (create, update, delete) are not loaded
186 | - The Docker proxy requests tool is not loaded
187 | - The Kubernetes proxy requests tool is not loaded
188 | 
189 | # Portainer Version Support
190 | 
191 | 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.
192 | 
193 | | Portainer MCP Version  | Supported Portainer Version |
194 | |--------------|----------------------------|
195 | | 0.1.0 | 2.28.1 |
196 | | 0.2.0 | 2.28.1 |
197 | | 0.3.0 | 2.28.1 |
198 | | 0.4.0 | 2.29.2 |
199 | | 0.4.1 | 2.29.2 |
200 | | 0.5.0 | 2.30.0 |
201 | | 0.6.0 | 2.31.2 |
202 | 
203 | > [!NOTE]
204 | > 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.
205 | 
206 | # Supported Capabilities
207 | 
208 | The following table lists the currently (latest version) supported operations through MCP tools:
209 | 
210 | | Resource | Operation | Description | Supported In Version |
211 | |----------|-----------|-------------|----------------------|
212 | | **Environments** | | | |
213 | | | ListEnvironments | List all available environments | 0.1.0 |
214 | | | UpdateEnvironmentTags | Update tags associated with an environment | 0.1.0 |
215 | | | UpdateEnvironmentUserAccesses | Update user access policies for an environment | 0.1.0 |
216 | | | UpdateEnvironmentTeamAccesses | Update team access policies for an environment | 0.1.0 |
217 | | **Environment Groups (Edge Groups)** | | | |
218 | | | ListEnvironmentGroups | List all available environment groups | 0.1.0 |
219 | | | CreateEnvironmentGroup | Create a new environment group | 0.1.0 |
220 | | | UpdateEnvironmentGroupName | Update the name of an environment group | 0.1.0 |
221 | | | UpdateEnvironmentGroupEnvironments | Update environments associated with a group | 0.1.0 |
222 | | | UpdateEnvironmentGroupTags | Update tags associated with a group | 0.1.0 |
223 | | **Access Groups (Endpoint Groups)** | | | |
224 | | | ListAccessGroups | List all available access groups | 0.1.0 |
225 | | | CreateAccessGroup | Create a new access group | 0.1.0 |
226 | | | UpdateAccessGroupName | Update the name of an access group | 0.1.0 |
227 | | | UpdateAccessGroupUserAccesses | Update user accesses for an access group | 0.1.0 |
228 | | | UpdateAccessGroupTeamAccesses | Update team accesses for an access group | 0.1.0 |
229 | | | AddEnvironmentToAccessGroup | Add an environment to an access group | 0.1.0 |
230 | | | RemoveEnvironmentFromAccessGroup | Remove an environment from an access group | 0.1.0 |
231 | | **Stacks (Edge Stacks)** | | | |
232 | | | ListStacks | List all available stacks | 0.1.0 |
233 | | | GetStackFile | Get the compose file for a specific stack | 0.1.0 |
234 | | | CreateStack | Create a new Docker stack | 0.1.0 |
235 | | | UpdateStack | Update an existing Docker stack | 0.1.0 |
236 | | **Tags** | | | |
237 | | | ListEnvironmentTags | List all available environment tags | 0.1.0 |
238 | | | CreateEnvironmentTag | Create a new environment tag | 0.1.0 |
239 | | **Teams** | | | |
240 | | | ListTeams | List all available teams | 0.1.0 |
241 | | | CreateTeam | Create a new team | 0.1.0 |
242 | | | UpdateTeamName | Update the name of a team | 0.1.0 |
243 | | | UpdateTeamMembers | Update the members of a team | 0.1.0 |
244 | | **Users** | | | |
245 | | | ListUsers | List all available users | 0.1.0 |
246 | | | UpdateUser | Update an existing user | 0.1.0 |
247 | | | GetSettings | Get the settings of the Portainer instance | 0.1.0 |
248 | | **Docker** | | | |
249 | | | DockerProxy | Proxy ANY Docker API requests | 0.2.0 |
250 | | **Kubernetes** | | | |
251 | | | KubernetesProxy | Proxy ANY Kubernetes API requests | 0.3.0 |
252 | | | getKubernetesResourceStripped | Proxy GET Kubernetes API requests and automatically strip verbose metadata fields | 0.6.0 |
253 | 
254 | # Development
255 | 
256 | ## Code Statistics
257 | 
258 | 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`).
259 | 
260 | Run the script from the repository root to see the default summary output:
261 | 
262 | ```bash
263 | ./cloc.sh
264 | ```
265 | 
266 | Refer to the comment header within the `cloc.sh` script for details on available flags to retrieve specific metrics.
267 | 
268 | ## Token Counting
269 | 
270 | 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.
271 | 
272 | **1. Generate the Tools JSON:**
273 | 
274 | 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:
275 | 
276 | ```bash
277 | # Replace internal/tooldef/tools.yaml with your YAML file if different
278 | # Replace .tmp/tools.json with your desired output path
279 | go run ./cmd/token-count -input internal/tooldef/tools.yaml -output .tmp/tools.json
280 | ```
281 | 
282 | 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.
283 | 
284 | **2. Query the Anthropic API:**
285 | 
286 | 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.
287 | 
288 | ```bash
289 | # Ensure you have jq installed
290 | # Replace sk-ant-xxxxxxxx with your actual Anthropic API key
291 | # Replace .tmp/tools.json with the path to the file generated in step 1
292 | ./token.sh -k sk-ant-xxxxxxxx -i .tmp/tools.json
293 | ```
294 | 
295 | 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.
296 | 
297 | This process helps in understanding the token cost associated with the toolset provided to the language model.
298 | 
```

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

```markdown
  1 | # Portainer MCP Development Guide
  2 | 
  3 | ## Build, Test & Run Commands
  4 | - Build: `make build`
  5 | - Run tests: `go test -v ./...`
  6 | - Run single test: `go test -v ./path/to/package -run TestName`
  7 | - Lint: `go vet ./...` and `golint ./...` (install golint if needed)
  8 | - Format code: `gofmt -s -w .`
  9 | - Run inspector: `make inspector`
 10 | - Build for specific platform: `make PLATFORM=<platform> ARCH=<arch> build`
 11 | - Integration tests: `make test-integration`
 12 | - Run all tests: `make test-all`
 13 | 
 14 | ## Code Style Guidelines
 15 | - Use standard Go naming conventions: PascalCase for exported, camelCase for private
 16 | - Follow table-driven test pattern with descriptive test cases
 17 | - Error handling: return errors with context via `fmt.Errorf("failed to X: %w", err)`
 18 | - Imports: group standard library, external packages, and internal packages
 19 | - Function comments: document exported functions with Parameters/Returns sections
 20 | - Use functional options pattern for configurable clients
 21 | - Package structure: cmd/ for entry points, internal/ for implementation, pkg/ for reusable components
 22 | - Models belong in pkg/portainer/models, client implementations in pkg/portainer/client
 23 | 
 24 | ## Design Documentation
 25 | - Design decisions are documented in individual files in `docs/design/` directory
 26 | - Follow the naming convention: `YYMMDD-N-short-description.md` where:
 27 |   - `YYMMDD` is the date (year-month-day)
 28 |   - `N` is a sequence number for that date
 29 |   - Example: `202505-1-feature-toggles.md`
 30 | - Use the standard template structure provided in `docs/design_summary.md`
 31 | - Add new decisions to the table in `docs/design_summary.md`
 32 | - Review existing decisions before making significant architectural changes
 33 | 
 34 | ## Client and Model Guidelines
 35 | 
 36 | ### Client Structure
 37 | 1. **Raw Client** (`portainer/client-api-go/v2`)
 38 |    - Directly communicates with Portainer API
 39 |    - Used in integration tests for ground-truth comparisons
 40 |    - Works with raw models from `github.com/portainer/client-api-go/v2/pkg/models`
 41 | 
 42 | 2. **Wrapper Client** (`pkg/portainer/client`)
 43 |    - Abstraction layer over the Raw Client
 44 |    - Simplifies interface for the MCP application
 45 |    - Handles data transformation between Raw and Local Models
 46 |    - Used by MCP server handlers
 47 | 
 48 | ### Model Structure
 49 | 1. **Raw Models** (`portainer/client-api-go/v2/pkg/models`)
 50 |    - Direct mapping to Portainer API data structures
 51 |    - May contain fields not relevant to MCP
 52 |    - Prefix variables with `raw` (e.g., `rawSettings`, `rawEndpoint`)
 53 | 
 54 | 2. **Local Models** (`pkg/portainer/models`)
 55 |    - Simplified structures tailored for the MCP application
 56 |    - Contain only relevant fields with convenient types
 57 |    - Define conversion functions to transform from Raw Models
 58 | 
 59 | ### Import Conventions
 60 | ```go
 61 | import (
 62 |     "github.com/portainer/portainer-mcp/pkg/portainer/models" // Default: models (Local MCP Models)
 63 |     apimodels "github.com/portainer/client-api-go/v2/pkg/models" // Alias: apimodels (Raw Client-API-Go Models)
 64 | )
 65 | ```
 66 | 
 67 | ### Testing Approach
 68 | - **Unit Tests**: Mock Raw Client interface, verify conversions and expected Local Model output
 69 | - **Integration Tests**: Call MCP handler and compare with ground-truth from Raw Client
 70 | 
 71 | ## MCP Server Architecture
 72 | 
 73 | ### Server Configuration
 74 | - Server is initialized in `cmd/portainer-mcp/mcp.go`
 75 | - Uses functional options pattern via `WithClient()` and `WithReadOnly()`
 76 | - Connects to Portainer API using token-based authentication
 77 | - Validates compatibility with specific Portainer version
 78 | - Loads tool definitions from YAML file
 79 | 
 80 | ### Tool Definitions
 81 | - Tools are defined in `internal/tooldef/tools.yaml`
 82 | - File is embedded in binary at build time
 83 | - External file can override embedded definitions
 84 | - Version checking ensures compatibility
 85 | - Read-only mode restricts modification capabilities
 86 | 
 87 | ### Handler Pattern
 88 | - Each tool has a corresponding handler in `internal/mcp/`
 89 | - Handlers follow ToolHandlerFunc signature
 90 | - Standard error handling with wrapped errors
 91 | - Parameter validation with required flag checks
 92 | - Response serialization to JSON
 93 | 
 94 | ## Integration Testing Framework
 95 | 
 96 | ### Test Environment Setup
 97 | - Uses Docker containers for Portainer instances
 98 | - `tests/integration/helpers/test_env.go` provides test environment utilities
 99 | - Creates isolated test environment for each test
100 | - Configures both Raw Client and MCP Server for testing
101 | - Automatically cleans up resources after tests
102 | 
103 | ### Testing Conventions
104 | - Tests verify both success and error conditions
105 | - Use table-driven tests with descriptive case names
106 | - Compare MCP handler results with direct API calls
107 | - Validate correct error handling and parameter validation
108 | 
109 | ## Version Compatibility
110 | 
111 | ### Portainer Version Support
112 | - Each release supports a specific Portainer version (defined in `server.go`)
113 | - Version check at startup prevents compatibility issues
114 | - Fail-fast approach with clear error messaging
115 | 
116 | ### Tools File Versioning
117 | - Strict versioning for tools.yaml file
118 | - Version validation at startup
119 | - Clear upgrade path for breaking changes
120 | 
121 | ## Security Features
122 | 
123 | ### Read-Only Mode
124 | - Flag to enable read-only mode
125 | - Only registers tools that don't modify resources
126 | - Provides protection against accidental modifications
127 | - Safe mode for monitoring and observation
128 | 
129 | ### Error Handling
130 | - Validate parameters before performing operations
131 | - Proper error messages with context
132 | - Fail-fast approach for invalid operations
```

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

```yaml
1 | version: v1.0
2 | tools:
3 |   - name: test_tool
4 |     description: Test tool description
5 |     parameters:
6 |       - name: test_param
7 |         type: string
8 |         description: A test parameter
9 |         required: true 
```

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

```yaml
 1 | version: "0.5"
 2 | tools:
 3 |   - name: test_tool
 4 |     description: Test tool description
 5 |     parameters:
 6 |       - name: test_param
 7 |         type: string
 8 |         description: A test parameter
 9 |         required: true
10 | 
```

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

```go
 1 | package client
 2 | 
 3 | import "fmt"
 4 | 
 5 | func (c *PortainerClient) GetVersion() (string, error) {
 6 | 	version, err := c.cli.GetVersion()
 7 | 	if err != nil {
 8 | 		return "", fmt.Errorf("failed to get version: %w", err)
 9 | 	}
10 | 
11 | 	return version, nil
12 | }
13 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 
 6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 7 | )
 8 | 
 9 | func (c *PortainerClient) GetSettings() (models.PortainerSettings, error) {
10 | 	settings, err := c.cli.GetSettings()
11 | 	if err != nil {
12 | 		return models.PortainerSettings{}, fmt.Errorf("failed to get settings: %w", err)
13 | 	}
14 | 
15 | 	return models.ConvertSettingsToPortainerSettings(settings), nil
16 | }
17 | 
```

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

```go
 1 | package tooldef
 2 | 
 3 | import (
 4 | 	_ "embed"
 5 | 	"os"
 6 | )
 7 | 
 8 | //go:embed tools.yaml
 9 | var ToolsFile []byte
10 | 
11 | // CreateToolsFileIfNotExists creates the tools.yaml file if it doesn't exist
12 | // It returns true if the file already exists, false if it was created or an error occurred
13 | func CreateToolsFileIfNotExists(path string) (bool, error) {
14 | 	if _, err := os.Stat(path); os.IsNotExist(err) {
15 | 		err = os.WriteFile(path, ToolsFile, 0644)
16 | 		if err != nil {
17 | 			return false, err
18 | 		}
19 | 		return false, nil
20 | 	}
21 | 	return true, nil
22 | }
23 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 5 | )
 6 | 
 7 | type Team struct {
 8 | 	ID        int    `json:"id"`
 9 | 	Name      string `json:"name"`
10 | 	MemberIDs []int  `json:"members"`
11 | }
12 | 
13 | func ConvertToTeam(rawTeam *apimodels.PortainerTeam, rawMemberships []*apimodels.PortainerTeamMembership) Team {
14 | 	memberIDs := make([]int, 0)
15 | 	for _, member := range rawMemberships {
16 | 		if member.TeamID == rawTeam.ID {
17 | 			memberIDs = append(memberIDs, int(member.UserID))
18 | 		}
19 | 	}
20 | 
21 | 	return Team{
22 | 		ID:        int(rawTeam.ID),
23 | 		Name:      rawTeam.Name,
24 | 		MemberIDs: memberIDs,
25 | 	}
26 | }
27 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 5 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
 6 | )
 7 | 
 8 | type Group struct {
 9 | 	ID             int    `json:"id"`
10 | 	Name           string `json:"name"`
11 | 	EnvironmentIds []int  `json:"environment_ids"`
12 | 	TagIds         []int  `json:"tag_ids"`
13 | }
14 | 
15 | func ConvertEdgeGroupToGroup(rawEdgeGroup *apimodels.EdgegroupsDecoratedEdgeGroup) Group {
16 | 	return Group{
17 | 		ID:             int(rawEdgeGroup.ID),
18 | 		Name:           rawEdgeGroup.Name,
19 | 		EnvironmentIds: utils.Int64ToIntSlice(rawEdgeGroup.Endpoints),
20 | 		TagIds:         utils.Int64ToIntSlice(rawEdgeGroup.TagIds),
21 | 	}
22 | }
23 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"strconv"
 5 | 
 6 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 7 | )
 8 | 
 9 | type EnvironmentTag struct {
10 | 	ID             int    `json:"id"`
11 | 	Name           string `json:"name"`
12 | 	EnvironmentIds []int  `json:"environment_ids"`
13 | }
14 | 
15 | func ConvertTagToEnvironmentTag(rawTag *apimodels.PortainerTag) EnvironmentTag {
16 | 	environmentIDs := make([]int, 0, len(rawTag.Endpoints))
17 | 
18 | 	for endpointID := range rawTag.Endpoints {
19 | 		id, err := strconv.Atoi(endpointID)
20 | 		if err == nil {
21 | 			environmentIDs = append(environmentIDs, id)
22 | 		}
23 | 	}
24 | 
25 | 	return EnvironmentTag{
26 | 		ID:             int(rawTag.ID),
27 | 		Name:           rawTag.Name,
28 | 		EnvironmentIds: environmentIDs,
29 | 	}
30 | }
31 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"time"
 5 | 
 6 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 7 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
 8 | )
 9 | 
10 | type Stack struct {
11 | 	ID                  int    `json:"id"`
12 | 	Name                string `json:"name"`
13 | 	CreatedAt           string `json:"created_at"`
14 | 	EnvironmentGroupIds []int  `json:"group_ids"`
15 | }
16 | 
17 | func ConvertEdgeStackToStack(rawEdgeStack *apimodels.PortainereeEdgeStack) Stack {
18 | 	createdAt := time.Unix(rawEdgeStack.CreationDate, 0).Format(time.RFC3339)
19 | 
20 | 	return Stack{
21 | 		ID:                  int(rawEdgeStack.ID),
22 | 		Name:                rawEdgeStack.Name,
23 | 		CreatedAt:           createdAt,
24 | 		EnvironmentGroupIds: utils.Int64ToIntSlice(rawEdgeStack.EdgeGroups),
25 | 	}
26 | }
27 | 
```

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

```go
 1 | package mcp
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"encoding/json"
 6 | 
 7 | 	"github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/mark3labs/mcp-go/server"
 9 | )
10 | 
11 | func (s *PortainerMCPServer) AddSettingsFeatures() {
12 | 	s.addToolIfExists(ToolGetSettings, s.HandleGetSettings())
13 | }
14 | 
15 | func (s *PortainerMCPServer) HandleGetSettings() server.ToolHandlerFunc {
16 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
17 | 		settings, err := s.cli.GetSettings()
18 | 		if err != nil {
19 | 			return mcp.NewToolResultErrorFromErr("failed to get settings", err), nil
20 | 		}
21 | 
22 | 		data, err := json.Marshal(settings)
23 | 		if err != nil {
24 | 			return mcp.NewToolResultErrorFromErr("failed to marshal settings", err), nil
25 | 		}
26 | 
27 | 		return mcp.NewToolResultText(string(data)), nil
28 | 	}
29 | }
30 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"strconv"
 5 | 
 6 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 7 | )
 8 | 
 9 | func convertAccesses[T apimodels.PortainerUserAccessPolicies | apimodels.PortainerTeamAccessPolicies](rawPolicies T) map[int]string {
10 | 	accesses := make(map[int]string)
11 | 	for idStr, role := range rawPolicies {
12 | 		id, err := strconv.Atoi(idStr)
13 | 		if err == nil {
14 | 			accesses[id] = convertAccessPolicyRole(&role)
15 | 		}
16 | 	}
17 | 	return accesses
18 | }
19 | 
20 | func convertAccessPolicyRole(rawPolicy *apimodels.PortainerAccessPolicy) string {
21 | 	switch rawPolicy.RoleID {
22 | 	case 1:
23 | 		return "environment_administrator"
24 | 	case 2:
25 | 		return "helpdesk_user"
26 | 	case 3:
27 | 		return "standard_user"
28 | 	case 4:
29 | 		return "readonly_user"
30 | 	case 5:
31 | 		return "operator_user"
32 | 	default:
33 | 		return "unknown"
34 | 	}
35 | }
36 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 5 | )
 6 | 
 7 | type User struct {
 8 | 	ID       int    `json:"id"`
 9 | 	Username string `json:"username"`
10 | 	Role     string `json:"role"`
11 | }
12 | 
13 | // User role constants
14 | const (
15 | 	UserRoleAdmin     = "admin"
16 | 	UserRoleUser      = "user"
17 | 	UserRoleEdgeAdmin = "edge_admin"
18 | 	UserRoleUnknown   = "unknown"
19 | )
20 | 
21 | func ConvertToUser(rawUser *apimodels.PortainereeUser) User {
22 | 	return User{
23 | 		ID:       int(rawUser.ID),
24 | 		Username: rawUser.Username,
25 | 		Role:     convertUserRole(rawUser),
26 | 	}
27 | }
28 | 
29 | func convertUserRole(rawUser *apimodels.PortainereeUser) string {
30 | 	switch rawUser.Role {
31 | 	case 1:
32 | 		return UserRoleAdmin
33 | 	case 2:
34 | 		return UserRoleUser
35 | 	case 3:
36 | 		return UserRoleEdgeAdmin
37 | 	default:
38 | 		return UserRoleUnknown
39 | 	}
40 | }
41 | 
```

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

```go
 1 | package models
 2 | 
 3 | import "io"
 4 | 
 5 | // DockerProxyRequestOptions represents the options for a Docker API request to a specific Portainer environment.
 6 | type DockerProxyRequestOptions struct {
 7 | 	// EnvironmentID is the ID of the environment to proxy the request to.
 8 | 	EnvironmentID int
 9 | 	// Method is the HTTP method to use (GET, POST, PUT, DELETE, etc.).
10 | 	Method string
11 | 	// Path is the Docker API endpoint path to proxy to (e.g., "/containers/json"). Must include the leading slash.
12 | 	Path string
13 | 	// QueryParams is a map of query parameters to include in the request URL.
14 | 	QueryParams map[string]string
15 | 	// Headers is a map of headers to include in the request.
16 | 	Headers map[string]string
17 | 	// Body is the request body to send (set it to nil for requests that don't have a body).
18 | 	Body io.Reader
19 | }
20 | 
```

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

```go
 1 | package models
 2 | 
 3 | import "io"
 4 | 
 5 | // KubernetesProxyRequestOptions represents the options for a Kubernetes API request to a specific Portainer environment.
 6 | type KubernetesProxyRequestOptions struct {
 7 | 	// EnvironmentID is the ID of the environment to proxy the request to.
 8 | 	EnvironmentID int
 9 | 	// Method is the HTTP method to use (GET, POST, PUT, DELETE, etc.).
10 | 	Method string
11 | 	// Path is the Kubernetes API endpoint path to proxy to (e.g., "/api/v1/namespaces/default/pods"). Must include the leading slash.
12 | 	Path string
13 | 	// QueryParams is a map of query parameters to include in the request URL.
14 | 	QueryParams map[string]string
15 | 	// Headers is a map of headers to include in the request.
16 | 	Headers map[string]string
17 | 	// Body is the request body to send (set it to nil for requests that don't have a body).
18 | 	Body io.Reader
19 | }
20 | 
```

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

```go
 1 | package utils
 2 | 
 3 | // Int64ToIntSlice converts a slice of int64 values to a slice of int values.
 4 | // This may result in data loss if the int64 values exceed the range of int.
 5 | func Int64ToIntSlice(int64s []int64) []int {
 6 | 	ints := make([]int, len(int64s))
 7 | 	for i, int64 := range int64s {
 8 | 		ints[i] = int(int64)
 9 | 	}
10 | 	return ints
11 | }
12 | 
13 | // IntToInt64Slice converts a slice of int values to a slice of int64 values.
14 | func IntToInt64Slice(ints []int) []int64 {
15 | 	int64s := make([]int64, len(ints))
16 | 	for i, int := range ints {
17 | 		int64s[i] = int64(int)
18 | 	}
19 | 	return int64s
20 | }
21 | 
22 | // IntToInt64Map converts a map with int keys to a map with int64 keys.
23 | // The string values remain unchanged.
24 | func IntToInt64Map(intMap map[int]string) map[int64]string {
25 | 	int64Map := make(map[int64]string, len(intMap))
26 | 	for key, value := range intMap {
27 | 		int64Map[int64(key)] = value
28 | 	}
29 | 	return int64Map
30 | }
31 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"net/http"
 5 | 
 6 | 	"github.com/portainer/client-api-go/v2/client"
 7 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 8 | )
 9 | 
10 | // ProxyDockerRequest proxies a Docker API request to a specific Portainer environment.
11 | //
12 | // Parameters:
13 | //   - opts: Options defining the proxied request (environmentID, method, path, query params, headers, body)
14 | //
15 | // Returns:
16 | //   - *http.Response: The response from the Docker API
17 | //   - error: Any error that occurred during the request
18 | func (c *PortainerClient) ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error) {
19 | 	proxyOpts := client.ProxyRequestOptions{
20 | 		Method:  opts.Method,
21 | 		APIPath: opts.Path,
22 | 		Body:    opts.Body,
23 | 	}
24 | 
25 | 	if len(opts.QueryParams) > 0 {
26 | 		proxyOpts.QueryParams = opts.QueryParams
27 | 	}
28 | 
29 | 	if len(opts.Headers) > 0 {
30 | 		proxyOpts.Headers = opts.Headers
31 | 	}
32 | 
33 | 	return c.cli.ProxyDockerRequest(opts.EnvironmentID, proxyOpts)
34 | }
35 | 
```

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

```yaml
 1 | name: Release
 2 | 
 3 | on:
 4 |   release:
 5 |     types: [created]
 6 | 
 7 | permissions:
 8 |   contents: write
 9 | 
10 | jobs:
11 |   releases-matrix:
12 |     name: Release Go Binary
13 |     runs-on: ubuntu-latest
14 |     strategy:
15 |       matrix:
16 |         goos: [linux, darwin]
17 |         goarch: [amd64, arm64]
18 |         exclude:
19 |           - goarch: "amd64"
20 |             goos: darwin
21 |     steps:
22 |     - uses: actions/checkout@v4
23 |     - id: get_version
24 |       uses: battila7/get-version-action@v2
25 |     - name: Set build time
26 |       run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV}
27 |     - uses: wangyoucao577/go-release-action@v1
28 |       with:
29 |         github_token: ${{ secrets.GITHUB_TOKEN }}
30 |         goos: ${{ matrix.goos }}
31 |         goarch: ${{ matrix.goarch }}
32 |         project_path: "./cmd/portainer-mcp"
33 |         build_flags: "-a --installsuffix cgo"
34 |         ldflags: -s -w -X "main.Version=${{ steps.get_version.outputs.version }}" -X "main.BuildDate=${{ env.BUILD_TIME }}" -X main.Commit=${{ github.sha }}
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 5 | )
 6 | 
 7 | type AccessGroup struct {
 8 | 	ID             int            `json:"id"`
 9 | 	Name           string         `json:"name"`
10 | 	EnvironmentIds []int          `json:"environment_ids"`
11 | 	UserAccesses   map[int]string `json:"user_accesses"`
12 | 	TeamAccesses   map[int]string `json:"team_accesses"`
13 | }
14 | 
15 | func ConvertEndpointGroupToAccessGroup(rawGroup *apimodels.PortainerEndpointGroup, rawEndpoints []*apimodels.PortainereeEndpoint) AccessGroup {
16 | 	environmentIds := make([]int, 0)
17 | 	for _, env := range rawEndpoints {
18 | 		if env.GroupID == rawGroup.ID {
19 | 			environmentIds = append(environmentIds, int(env.ID))
20 | 		}
21 | 	}
22 | 
23 | 	return AccessGroup{
24 | 		ID:             int(rawGroup.ID),
25 | 		Name:           rawGroup.Name,
26 | 		EnvironmentIds: environmentIds,
27 | 		UserAccesses:   convertAccesses(rawGroup.UserAccessPolicies),
28 | 		TeamAccesses:   convertAccesses(rawGroup.TeamAccessPolicies),
29 | 	}
30 | }
31 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"net/http"
 5 | 
 6 | 	"github.com/portainer/client-api-go/v2/client"
 7 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 8 | )
 9 | 
10 | // ProxyKubernetesRequest proxies a Kubernetes API request to a specific Portainer environment.
11 | //
12 | // Parameters:
13 | //   - opts: Options defining the proxied request (environmentID, method, path, query params, headers, body)
14 | //
15 | // Returns:
16 | //   - *http.Response: The response from the Kubernetes API
17 | //   - error: Any error that occurred during the request
18 | func (c *PortainerClient) ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error) {
19 | 	proxyOpts := client.ProxyRequestOptions{
20 | 		Method:  opts.Method,
21 | 		APIPath: opts.Path,
22 | 		Body:    opts.Body,
23 | 	}
24 | 
25 | 	if len(opts.QueryParams) > 0 {
26 | 		proxyOpts.QueryParams = opts.QueryParams
27 | 	}
28 | 
29 | 	if len(opts.Headers) > 0 {
30 | 		proxyOpts.Headers = opts.Headers
31 | 	}
32 | 
33 | 	return c.cli.ProxyKubernetesRequest(opts.EnvironmentID, proxyOpts)
34 | }
35 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 	"testing"
 6 | 
 7 | 	"github.com/stretchr/testify/assert"
 8 | )
 9 | 
10 | func TestGetVersion(t *testing.T) {
11 | 	tests := []struct {
12 | 		name           string
13 | 		mockVersion    string
14 | 		mockError      error
15 | 		expectedResult string
16 | 		expectedError  bool
17 | 	}{
18 | 		{
19 | 			name:           "successful retrieval",
20 | 			mockVersion:    "2.19.0",
21 | 			mockError:      nil,
22 | 			expectedResult: "2.19.0",
23 | 			expectedError:  false,
24 | 		},
25 | 		{
26 | 			name:           "api error",
27 | 			mockVersion:    "",
28 | 			mockError:      fmt.Errorf("api error"),
29 | 			expectedResult: "",
30 | 			expectedError:  true,
31 | 		},
32 | 	}
33 | 
34 | 	for _, tt := range tests {
35 | 		t.Run(tt.name, func(t *testing.T) {
36 | 			mockAPI := new(MockPortainerAPI)
37 | 			mockAPI.On("GetVersion").Return(tt.mockVersion, tt.mockError)
38 | 
39 | 			client := &PortainerClient{
40 | 				cli: mockAPI,
41 | 			}
42 | 
43 | 			version, err := client.GetVersion()
44 | 
45 | 			if tt.expectedError {
46 | 				assert.Error(t, err)
47 | 				assert.Equal(t, "", version)
48 | 			} else {
49 | 				assert.NoError(t, err)
50 | 				assert.Equal(t, tt.expectedResult, version)
51 | 			}
52 | 
53 | 			mockAPI.AssertExpectations(t)
54 | 		})
55 | 	}
56 | }
57 | 
```

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

```go
 1 | package models
 2 | 
 3 | import apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 4 | 
 5 | type PortainerSettings struct {
 6 | 	Authentication struct {
 7 | 		Method string `json:"method"`
 8 | 	} `json:"authentication"`
 9 | 	Edge struct {
10 | 		Enabled   bool   `json:"enabled"`
11 | 		ServerURL string `json:"server_url"`
12 | 	} `json:"edge"`
13 | }
14 | 
15 | const (
16 | 	AuthenticationMethodInternal = "internal"
17 | 	AuthenticationMethodLDAP     = "ldap"
18 | 	AuthenticationMethodOAuth    = "oauth"
19 | 	AuthenticationMethodUnknown  = "unknown"
20 | )
21 | 
22 | func ConvertSettingsToPortainerSettings(rawSettings *apimodels.PortainereeSettings) PortainerSettings {
23 | 	s := PortainerSettings{}
24 | 
25 | 	s.Authentication.Method = convertAuthenticationMethod(rawSettings.AuthenticationMethod)
26 | 	s.Edge.Enabled = rawSettings.EnableEdgeComputeFeatures
27 | 	s.Edge.ServerURL = rawSettings.Edge.TunnelServerAddress
28 | 
29 | 	return s
30 | }
31 | 
32 | func convertAuthenticationMethod(method int64) string {
33 | 	switch method {
34 | 	case 1:
35 | 		return AuthenticationMethodInternal
36 | 	case 2:
37 | 		return AuthenticationMethodLDAP
38 | 	case 3:
39 | 		return AuthenticationMethodOAuth
40 | 	default:
41 | 		return AuthenticationMethodUnknown
42 | 	}
43 | }
44 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 
 6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 7 | )
 8 | 
 9 | // GetEnvironmentTags retrieves all environment tags from the Portainer server.
10 | // Environment tags are the equivalent of Tags in Portainer.
11 | //
12 | // Returns:
13 | //   - A slice of EnvironmentTag objects
14 | //   - An error if the operation fails
15 | func (c *PortainerClient) GetEnvironmentTags() ([]models.EnvironmentTag, error) {
16 | 	tags, err := c.cli.ListTags()
17 | 	if err != nil {
18 | 		return nil, fmt.Errorf("failed to list environment tags: %w", err)
19 | 	}
20 | 
21 | 	environmentTags := make([]models.EnvironmentTag, len(tags))
22 | 	for i, tag := range tags {
23 | 		environmentTags[i] = models.ConvertTagToEnvironmentTag(tag)
24 | 	}
25 | 
26 | 	return environmentTags, nil
27 | }
28 | 
29 | // CreateEnvironmentTag creates a new environment tag on the Portainer server.
30 | // Environment tags are the equivalent of Tags in Portainer.
31 | //
32 | // Parameters:
33 | //   - name: The name of the environment tag
34 | //
35 | // Returns:
36 | //   - The ID of the created environment tag
37 | //   - An error if the operation fails
38 | func (c *PortainerClient) CreateEnvironmentTag(name string) (int, error) {
39 | 	id, err := c.cli.CreateTag(name)
40 | 	if err != nil {
41 | 		return 0, fmt.Errorf("failed to create environment tag: %w", err)
42 | 	}
43 | 
44 | 	return int(id), nil
45 | }
46 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 
 6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 7 | )
 8 | 
 9 | // GetUsers retrieves all users from the Portainer server.
10 | //
11 | // Returns:
12 | //   - A slice of User objects containing user information
13 | //   - An error if the operation fails
14 | func (c *PortainerClient) GetUsers() ([]models.User, error) {
15 | 	portainerUsers, err := c.cli.ListUsers()
16 | 	if err != nil {
17 | 		return nil, fmt.Errorf("failed to list users: %w", err)
18 | 	}
19 | 
20 | 	users := make([]models.User, len(portainerUsers))
21 | 	for i, user := range portainerUsers {
22 | 		users[i] = models.ConvertToUser(user)
23 | 	}
24 | 
25 | 	return users, nil
26 | }
27 | 
28 | // UpdateUserRole updates the role of a user.
29 | //
30 | // Parameters:
31 | //   - id: The ID of the user to update
32 | //   - role: The new role for the user. Must be one of: admin, user, edge_admin
33 | //
34 | // Returns:
35 | //   - An error if the operation fails
36 | func (c *PortainerClient) UpdateUserRole(id int, role string) error {
37 | 	roleInt := convertRole(role)
38 | 	if roleInt == 0 {
39 | 		return fmt.Errorf("invalid role: must be admin, user or edge_admin")
40 | 	}
41 | 
42 | 	return c.cli.UpdateUserRole(id, roleInt)
43 | }
44 | 
45 | func convertRole(role string) int64 {
46 | 	switch role {
47 | 	case models.UserRoleAdmin:
48 | 		return 1
49 | 	case models.UserRoleUser:
50 | 		return 2
51 | 	case models.UserRoleEdgeAdmin:
52 | 		return 3
53 | 	default:
54 | 		return 0
55 | 	}
56 | }
57 | 
```

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

```go
 1 | package mcp
 2 | 
 3 | import "testing"
 4 | 
 5 | func TestIsValidAccessLevel(t *testing.T) {
 6 | 	tests := []struct {
 7 | 		name        string
 8 | 		accessLevel string
 9 | 		want        bool
10 | 	}{
11 | 		{"ValidEnvironmentAdmin", AccessLevelEnvironmentAdmin, true},
12 | 		{"ValidHelpdeskUser", AccessLevelHelpdeskUser, true},
13 | 		{"ValidStandardUser", AccessLevelStandardUser, true},
14 | 		{"ValidReadonlyUser", AccessLevelReadonlyUser, true},
15 | 		{"ValidOperatorUser", AccessLevelOperatorUser, true},
16 | 		{"InvalidEmpty", "", false},
17 | 		{"InvalidRandom", "invalid_access", false},
18 | 		{"CaseSensitive", "ENVIRONMENT_ADMINISTRATOR", false},
19 | 	}
20 | 
21 | 	for _, tt := range tests {
22 | 		t.Run(tt.name, func(t *testing.T) {
23 | 			if got := isValidAccessLevel(tt.accessLevel); got != tt.want {
24 | 				t.Errorf("isValidAccessLevel(%q) = %v, want %v", tt.accessLevel, got, tt.want)
25 | 			}
26 | 		})
27 | 	}
28 | }
29 | 
30 | func TestIsValidUserRole(t *testing.T) {
31 | 	tests := []struct {
32 | 		name     string
33 | 		userRole string
34 | 		want     bool
35 | 	}{
36 | 		{"ValidAdmin", UserRoleAdmin, true},
37 | 		{"ValidUser", UserRoleUser, true},
38 | 		{"ValidEdgeAdmin", UserRoleEdgeAdmin, true},
39 | 		{"InvalidEmpty", "", false},
40 | 		{"InvalidRandom", "invalid_role", false},
41 | 		{"CaseSensitive", "ADMIN", false},
42 | 	}
43 | 
44 | 	for _, tt := range tests {
45 | 		t.Run(tt.name, func(t *testing.T) {
46 | 			if got := isValidUserRole(tt.userRole); got != tt.want {
47 | 				t.Errorf("isValidUserRole(%q) = %v, want %v", tt.userRole, got, tt.want)
48 | 			}
49 | 		})
50 | 	}
51 | }
52 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"testing"
 5 | 
 6 | 	"github.com/stretchr/testify/assert"
 7 | )
 8 | 
 9 | func TestNewPortainerClient(t *testing.T) {
10 | 	tests := []struct {
11 | 		name        string
12 | 		serverURL   string
13 | 		token       string
14 | 		opts        []ClientOption
15 | 		expectError bool
16 | 	}{
17 | 		{
18 | 			name:      "creates client with default options",
19 | 			serverURL: "https://portainer.example.com",
20 | 			token:     "test-token",
21 | 			opts:      nil,
22 | 		},
23 | 		{
24 | 			name:      "creates client with skip TLS verify",
25 | 			serverURL: "https://portainer.example.com",
26 | 			token:     "test-token",
27 | 			opts:      []ClientOption{WithSkipTLSVerify(true)},
28 | 		},
29 | 	}
30 | 
31 | 	for _, tt := range tests {
32 | 		t.Run(tt.name, func(t *testing.T) {
33 | 			// Create client
34 | 			c := NewPortainerClient(tt.serverURL, tt.token, tt.opts...)
35 | 
36 | 			// Assert client was created
37 | 			assert.NotNil(t, c)
38 | 			assert.NotNil(t, c.cli)
39 | 		})
40 | 	}
41 | }
42 | 
43 | func TestWithSkipTLSVerify(t *testing.T) {
44 | 	tests := []struct {
45 | 		name     string
46 | 		skip     bool
47 | 		expected bool
48 | 	}{
49 | 		{
50 | 			name:     "enables TLS verification skip",
51 | 			skip:     true,
52 | 			expected: true,
53 | 		},
54 | 		{
55 | 			name:     "disables TLS verification skip",
56 | 			skip:     false,
57 | 			expected: false,
58 | 		},
59 | 	}
60 | 
61 | 	for _, tt := range tests {
62 | 		t.Run(tt.name, func(t *testing.T) {
63 | 			// Create options
64 | 			options := &clientOptions{}
65 | 			opt := WithSkipTLSVerify(tt.skip)
66 | 			opt(options)
67 | 
68 | 			// Assert option was applied correctly
69 | 			assert.Equal(t, tt.expected, options.skipTLSVerify)
70 | 		})
71 | 	}
72 | }
73 | 
```

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

```yaml
 1 | name: CI
 2 | 
 3 | on:
 4 |   push:
 5 |     branches: [ "main" ]
 6 |   pull_request:
 7 |     branches: [ "main" ]
 8 |     types: [opened, reopened, synchronize]
 9 | 
10 | jobs:
11 |   build:
12 |     runs-on: ubuntu-latest
13 |     permissions:
14 |       contents: write
15 |     steps:
16 |       - name: "checkout the current branch"
17 |         uses: actions/checkout@v4
18 |       - name: "set up golang"
19 |         uses: actions/[email protected]
20 |         with:
21 |           go-version-file: go.mod
22 |           cache-dependency-path: go.sum
23 |       - name: "Build the binary"
24 |         run: make build
25 |       - name: "Run unit tests"
26 |         run: make test-coverage
27 |       - name: "Run integration tests"
28 |         run: make test-integration
29 |       - name: "check test coverage"
30 |         uses: vladopajic/go-test-coverage@v2
31 |         with:
32 |           profile: coverage.out
33 |           git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }}
34 |           git-branch: badges
35 |       - name: "Archive code coverage results"
36 |         uses: actions/upload-artifact@v4
37 |         with:
38 |           name: code-coverage
39 |           path: coverage.out
40 | 
41 |   code_coverage:
42 |     name: "Code coverage report"
43 |     runs-on: ubuntu-latest
44 |     needs: build
45 |     if: github.event_name == 'pull_request'
46 |     permissions:
47 |       contents: read
48 |       actions: read
49 |       pull-requests: write
50 |     steps:
51 |       - name: "checkout the current branch"
52 |         uses: actions/checkout@v4
53 |       - uses: fgrosse/[email protected]
54 |         with:
55 |           coverage-file-name: "coverage.out"
56 | 
```

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

```go
 1 | package helpers
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 	"testing"
 7 | 
 8 | 	"github.com/portainer/client-api-go/v2/client"
 9 | 	"github.com/portainer/portainer-mcp/internal/mcp"
10 | 	"github.com/portainer/portainer-mcp/tests/integration/containers"
11 | 	"github.com/stretchr/testify/require"
12 | )
13 | 
14 | const (
15 | 	ToolsPath = "../../internal/tooldef/tools.yaml"
16 | )
17 | 
18 | // TestEnv holds the test environment configuration and clients
19 | type TestEnv struct {
20 | 	Ctx       context.Context
21 | 	Portainer *containers.PortainerContainer
22 | 	RawClient *client.PortainerClient
23 | 	MCPServer *mcp.PortainerMCPServer
24 | }
25 | 
26 | // NewTestEnv creates a new test environment with Portainer container and clients
27 | func NewTestEnv(t *testing.T, opts ...containers.PortainerContainerOption) *TestEnv {
28 | 	ctx := context.Background()
29 | 
30 | 	portainer, err := containers.NewPortainerContainer(ctx, opts...)
31 | 	require.NoError(t, err, "Failed to start Portainer container")
32 | 
33 | 	host, port := portainer.GetHostAndPort()
34 | 	serverURL := fmt.Sprintf("%s:%s", host, port)
35 | 
36 | 	rawCli := client.NewPortainerClient(
37 | 		serverURL,
38 | 		portainer.GetAPIToken(),
39 | 		client.WithSkipTLSVerify(true),
40 | 	)
41 | 
42 | 	mcpServer, err := mcp.NewPortainerMCPServer(serverURL, portainer.GetAPIToken(), ToolsPath)
43 | 	require.NoError(t, err, "Failed to create MCP server")
44 | 
45 | 	return &TestEnv{
46 | 		Ctx:       ctx,
47 | 		Portainer: portainer,
48 | 		RawClient: rawCli,
49 | 		MCPServer: mcpServer,
50 | 	}
51 | }
52 | 
53 | // Cleanup terminates the Portainer container
54 | func (e *TestEnv) Cleanup(t *testing.T) {
55 | 	if err := e.Portainer.Terminate(e.Ctx); err != nil {
56 | 		t.Logf("Failed to terminate container: %v", err)
57 | 	}
58 | }
59 | 
```

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

```go
 1 | package mcp
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"encoding/json"
 6 | 	"fmt"
 7 | 
 8 | 	"github.com/mark3labs/mcp-go/mcp"
 9 | 	"github.com/mark3labs/mcp-go/server"
10 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
11 | )
12 | 
13 | func (s *PortainerMCPServer) AddTagFeatures() {
14 | 	s.addToolIfExists(ToolListEnvironmentTags, s.HandleGetEnvironmentTags())
15 | 
16 | 	if !s.readOnly {
17 | 		s.addToolIfExists(ToolCreateEnvironmentTag, s.HandleCreateEnvironmentTag())
18 | 	}
19 | }
20 | 
21 | func (s *PortainerMCPServer) HandleGetEnvironmentTags() server.ToolHandlerFunc {
22 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
23 | 		environmentTags, err := s.cli.GetEnvironmentTags()
24 | 		if err != nil {
25 | 			return mcp.NewToolResultErrorFromErr("failed to get environment tags", err), nil
26 | 		}
27 | 
28 | 		data, err := json.Marshal(environmentTags)
29 | 		if err != nil {
30 | 			return mcp.NewToolResultErrorFromErr("failed to marshal environment tags", err), nil
31 | 		}
32 | 
33 | 		return mcp.NewToolResultText(string(data)), nil
34 | 	}
35 | }
36 | 
37 | func (s *PortainerMCPServer) HandleCreateEnvironmentTag() server.ToolHandlerFunc {
38 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
39 | 		parser := toolgen.NewParameterParser(request)
40 | 
41 | 		name, err := parser.GetString("name", true)
42 | 		if err != nil {
43 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
44 | 		}
45 | 
46 | 		id, err := s.cli.CreateEnvironmentTag(name)
47 | 		if err != nil {
48 | 			return mcp.NewToolResultErrorFromErr("failed to create environment tag", err), nil
49 | 		}
50 | 
51 | 		return mcp.NewToolResultText(fmt.Sprintf("Environment tag created successfully with ID: %d", id)), nil
52 | 	}
53 | }
54 | 
```

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

```go
 1 | package mcp
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"encoding/json"
 6 | 	"fmt"
 7 | 
 8 | 	"github.com/mark3labs/mcp-go/mcp"
 9 | 	"github.com/mark3labs/mcp-go/server"
10 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
11 | )
12 | 
13 | func (s *PortainerMCPServer) AddUserFeatures() {
14 | 	s.addToolIfExists(ToolListUsers, s.HandleGetUsers())
15 | 
16 | 	if !s.readOnly {
17 | 		s.addToolIfExists(ToolUpdateUserRole, s.HandleUpdateUserRole())
18 | 	}
19 | }
20 | 
21 | func (s *PortainerMCPServer) HandleGetUsers() server.ToolHandlerFunc {
22 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
23 | 		users, err := s.cli.GetUsers()
24 | 		if err != nil {
25 | 			return mcp.NewToolResultErrorFromErr("failed to get users", err), nil
26 | 		}
27 | 
28 | 		data, err := json.Marshal(users)
29 | 		if err != nil {
30 | 			return mcp.NewToolResultErrorFromErr("failed to marshal users", err), nil
31 | 		}
32 | 
33 | 		return mcp.NewToolResultText(string(data)), nil
34 | 	}
35 | }
36 | 
37 | func (s *PortainerMCPServer) HandleUpdateUserRole() server.ToolHandlerFunc {
38 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
39 | 		parser := toolgen.NewParameterParser(request)
40 | 
41 | 		id, err := parser.GetInt("id", true)
42 | 		if err != nil {
43 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
44 | 		}
45 | 
46 | 		role, err := parser.GetString("role", true)
47 | 		if err != nil {
48 | 			return mcp.NewToolResultErrorFromErr("invalid role parameter", err), nil
49 | 		}
50 | 
51 | 		if !isValidUserRole(role) {
52 | 			return mcp.NewToolResultError(fmt.Sprintf("invalid role %s: must be one of: %v", role, AllUserRoles)), nil
53 | 		}
54 | 
55 | 		err = s.cli.UpdateUserRole(id, role)
56 | 		if err != nil {
57 | 			return mcp.NewToolResultErrorFromErr("failed to update user role", err), nil
58 | 		}
59 | 
60 | 		return mcp.NewToolResultText("User updated successfully"), nil
61 | 	}
62 | }
63 | 
```

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

```markdown
 1 | # 202504-1: Embedding tools.yaml in the binary
 2 | 
 3 | **Date**: 08/04/2025
 4 | 
 5 | ### Context
 6 | After deciding to use an external tools.yaml file for tool definitions (see 202503-1), there was a need to determine the best distribution method for this file. Questions arose about how to ensure the file is available when the application runs.
 7 | 
 8 | ### Decision
 9 | Embed the tools.yaml file directly in the binary during the build process, while also checking for and using a user-provided version at runtime if available.
10 | 
11 | ### Rationale
12 | 1. **Simplified Distribution**
13 |    - Single binary contains everything needed to run the application
14 |    - No need to manage separate file distribution
15 |    - Eliminates file path configuration issues
16 | 
17 | 2. **User Customization**
18 |    - Application checks for external tools.yaml at startup
19 |    - If found, uses the external file for tool definitions
20 |    - If not found, creates it using the embedded version as reference
21 | 
22 | 3. **Default Configuration**
23 |    - Provides sensible defaults out of the box
24 |    - Ensures application can always run even without external configuration
25 |    - Serves as a reference for users who want to customize
26 | 
27 | 4. **Version Control**
28 |    - Embedded file serves as the official version for each release
29 |    - External file allows for hotfixes without binary updates
30 |    - Clear separation between default and custom configurations
31 | 
32 | ### Trade-offs
33 | 
34 | **Benefits**
35 | - Simpler distribution process
36 | - Self-contained application
37 | - Ability to run without configuration
38 | - Support for user customization
39 | - Clear fallback mechanism
40 | 
41 | **Challenges**
42 | - Slightly larger binary size
43 | - Need for embedding logic in the build process
44 | - Managing differences between embedded and external versions
45 | - Ensuring proper precedence between versions
```

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

```go
 1 | package mcp
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 	"slices"
 6 | 
 7 | 	"github.com/mark3labs/mcp-go/mcp"
 8 | )
 9 | 
10 | // parseAccessMap parses access entries from an array of objects and returns a map of ID to access level
11 | func parseAccessMap(entries []any) (map[int]string, error) {
12 | 	accessMap := map[int]string{}
13 | 
14 | 	for _, entry := range entries {
15 | 		entryMap, ok := entry.(map[string]any)
16 | 		if !ok {
17 | 			return nil, fmt.Errorf("invalid access entry: %v", entry)
18 | 		}
19 | 
20 | 		id, ok := entryMap["id"].(float64)
21 | 		if !ok {
22 | 			return nil, fmt.Errorf("invalid ID: %v", entryMap["id"])
23 | 		}
24 | 
25 | 		access, ok := entryMap["access"].(string)
26 | 		if !ok {
27 | 			return nil, fmt.Errorf("invalid access: %v", entryMap["access"])
28 | 		}
29 | 
30 | 		if !isValidAccessLevel(access) {
31 | 			return nil, fmt.Errorf("invalid access level: %s", access)
32 | 		}
33 | 
34 | 		accessMap[int(id)] = access
35 | 	}
36 | 
37 | 	return accessMap, nil
38 | }
39 | 
40 | // parseKeyValueMap parses a slice of map[string]any into a map[string]string,
41 | // expecting each map to have "key" and "value" string fields.
42 | func parseKeyValueMap(items []any) (map[string]string, error) {
43 | 	resultMap := map[string]string{}
44 | 
45 | 	for _, item := range items {
46 | 		itemMap, ok := item.(map[string]any)
47 | 		if !ok {
48 | 			return nil, fmt.Errorf("invalid item: %v", item)
49 | 		}
50 | 
51 | 		key, ok := itemMap["key"].(string)
52 | 		if !ok {
53 | 			return nil, fmt.Errorf("invalid key: %v", itemMap["key"])
54 | 		}
55 | 
56 | 		value, ok := itemMap["value"].(string)
57 | 		if !ok {
58 | 			return nil, fmt.Errorf("invalid value: %v", itemMap["value"])
59 | 		}
60 | 
61 | 		resultMap[key] = value
62 | 	}
63 | 
64 | 	return resultMap, nil
65 | }
66 | 
67 | func isValidHTTPMethod(method string) bool {
68 | 	validMethods := []string{"GET", "POST", "PUT", "DELETE", "HEAD"}
69 | 	return slices.Contains(validMethods, method)
70 | }
71 | 
72 | // CreateMCPRequest creates a new MCP tool request with the given arguments
73 | func CreateMCPRequest(args map[string]any) mcp.CallToolRequest {
74 | 	return mcp.CallToolRequest{
75 | 		Params: mcp.CallToolParams{
76 | 			Arguments: args,
77 | 		},
78 | 	}
79 | }
80 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"reflect"
 5 | 	"testing"
 6 | 
 7 | 	"github.com/portainer/client-api-go/v2/pkg/models"
 8 | )
 9 | 
10 | func TestConvertEdgeGroupToGroup(t *testing.T) {
11 | 	tests := []struct {
12 | 		name      string
13 | 		edgeGroup *models.EdgegroupsDecoratedEdgeGroup
14 | 		want      Group
15 | 	}{
16 | 		{
17 | 			name: "basic edge group conversion",
18 | 			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
19 | 				ID:        1,
20 | 				Name:      "Production Servers",
21 | 				Endpoints: []int64{1, 2, 3},
22 | 				TagIds:    []int64{1, 2},
23 | 			},
24 | 			want: Group{
25 | 				ID:             1,
26 | 				Name:           "Production Servers",
27 | 				EnvironmentIds: []int{1, 2, 3},
28 | 				TagIds:         []int{1, 2},
29 | 			},
30 | 		},
31 | 		{
32 | 			name: "edge group with no endpoints",
33 | 			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
34 | 				ID:        2,
35 | 				Name:      "Empty Group",
36 | 				Endpoints: []int64{},
37 | 				TagIds:    []int64{},
38 | 			},
39 | 			want: Group{
40 | 				ID:             2,
41 | 				Name:           "Empty Group",
42 | 				EnvironmentIds: []int{},
43 | 				TagIds:         []int{},
44 | 			},
45 | 		},
46 | 		{
47 | 			name: "edge group with single endpoint",
48 | 			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
49 | 				ID:        3,
50 | 				Name:      "Single Server",
51 | 				Endpoints: []int64{4},
52 | 			},
53 | 			want: Group{
54 | 				ID:             3,
55 | 				Name:           "Single Server",
56 | 				EnvironmentIds: []int{4},
57 | 				TagIds:         []int{},
58 | 			},
59 | 		},
60 | 		{
61 | 			name: "edge group with no tags",
62 | 			edgeGroup: &models.EdgegroupsDecoratedEdgeGroup{
63 | 				ID:        4,
64 | 				Name:      "No Tags Group",
65 | 				Endpoints: []int64{5},
66 | 				TagIds:    []int64{},
67 | 			},
68 | 			want: Group{
69 | 				ID:             4,
70 | 				Name:           "No Tags Group",
71 | 				EnvironmentIds: []int{5},
72 | 				TagIds:         []int{},
73 | 			},
74 | 		},
75 | 	}
76 | 
77 | 	for _, tt := range tests {
78 | 		t.Run(tt.name, func(t *testing.T) {
79 | 			got := ConvertEdgeGroupToGroup(tt.edgeGroup)
80 | 			if !reflect.DeepEqual(got, tt.want) {
81 | 				t.Errorf("ConvertEdgeGroupToGroup() = %v, want %v", got, tt.want)
82 | 			}
83 | 		})
84 | 	}
85 | }
86 | 
```

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

```go
 1 | package main
 2 | 
 3 | import (
 4 | 	"encoding/json"
 5 | 	"flag"
 6 | 	"os"
 7 | 
 8 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 9 | 	"github.com/rs/zerolog/log"
10 | )
11 | 
12 | // AnthropicTool defines the structure expected by the Anthropic API
13 | type AnthropicTool struct {
14 | 	Name        string `json:"name"`
15 | 	Description string `json:"description"`
16 | 	InputSchema any    `json:"input_schema"`
17 | 	// Annotations any    `json:"annotations"` // Annotations are currently not supported by the Anthropic API
18 | }
19 | 
20 | func main() {
21 | 	inputYamlPath := flag.String("input", "", "Path to the input tools YAML file (mandatory)")
22 | 	outputPath := flag.String("output", "", "Path to the output JSON file (mandatory)")
23 | 	flag.Parse()
24 | 
25 | 	if *inputYamlPath == "" {
26 | 		log.Fatal().Msg("Input YAML path is mandatory. Please specify using -input flag.")
27 | 	}
28 | 	if *outputPath == "" {
29 | 		log.Fatal().Msg("Output path is mandatory. Please specify using -output flag.")
30 | 	}
31 | 
32 | 	tools, err := toolgen.LoadToolsFromYAML(*inputYamlPath, "1.0")
33 | 	if err != nil {
34 | 		log.Fatal().Err(err).Msg("failed to load tools")
35 | 	}
36 | 
37 | 	// Convert map[string]mcp.Tool to []AnthropicTool for correct JSON structure
38 | 	var anthropicToolList []AnthropicTool
39 | 	for _, tool := range tools {
40 | 		// Only include fields expected by Anthropic
41 | 		anthropicTool := AnthropicTool{
42 | 			Name:        tool.Name,
43 | 			Description: tool.Description,
44 | 			InputSchema: tool.InputSchema, // Assuming mcp.Tool has InputSchema field
45 | 			// Annotations: tool.Annotations, // Removed annotations
46 | 		}
47 | 		anthropicToolList = append(anthropicToolList, anthropicTool)
48 | 	}
49 | 
50 | 	jsonData, err := json.MarshalIndent(anthropicToolList, "", "  ")
51 | 	if err != nil {
52 | 		log.Fatal().Err(err).Msg("failed to marshal tools to JSON")
53 | 	}
54 | 
55 | 	err = os.WriteFile(*outputPath, jsonData, 0644)
56 | 	if err != nil {
57 | 		log.Fatal().Err(err).Str("path", *outputPath).Msg("failed to write JSON to file")
58 | 	}
59 | 
60 | 	log.Info().Str("path", *outputPath).Msg("Successfully wrote tools to JSON file")
61 | }
62 | 
```

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

```markdown
 1 | # 202503-1: Using an external tools file for tool definition
 2 | 
 3 | **Date**: 29/03/2025
 4 | 
 5 | ### Context
 6 | The project needs to define and maintain a set of tools that interact with Portainer. Initially, these tool definitions could have been hardcoded within the application code.
 7 | 
 8 | ### Decision
 9 | Tool definitions are externalized into a separate `tools.yaml` file instead of maintaining them in the source code.
10 | 
11 | ### Rationale
12 | 1. **Improved Readability**
13 |    - Tool definitions often contain multi-line descriptions and complex parameter structures
14 |    - YAML format provides better readability and structure compared to in-code definitions
15 |    - Separates concerns: tool definitions from implementation logic
16 | 
17 | 2. **Dynamic Updates**
18 |    - Allows modification of tool descriptions and parameters without rebuilding the binary
19 |    - Enables rapid iteration on tool definitions
20 |    - Particularly valuable when experimenting with LLM interactions, as descriptions can be optimized for AI comprehension without code changes
21 | 
22 | 3. **Maintenance Benefits**
23 |    - Single source of truth for tool definitions
24 |    - Easier to review and validate changes to tool definitions
25 |    - Simplified version control for documentation changes
26 | 
27 | 4. **Version Management**
28 |    - External file format may need versioning as schema evolves
29 |    - Requires consideration of backward compatibility
30 |    - Enables tracking of breaking changes in tool definitions
31 | 
32 | ### Trade-offs
33 | 
34 | **Benefits**
35 | - More flexible maintenance of tool definitions
36 | - Better separation of concerns
37 | - Easier experimentation with LLM-optimized descriptions
38 | - Independent evolution of tool definitions and code
39 | - Improved visibility and security through externalized tool definitions, making it easier for users to audit and understand potential prompt injection risks
40 | 
41 | **Challenges**
42 | - Need to handle file loading and validation
43 | - Must ensure file distribution with the binary
44 | - Additional complexity in version management
```

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

```go
  1 | package models
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/portainer/client-api-go/v2/pkg/models"
  7 | )
  8 | 
  9 | func TestConvertToUser(t *testing.T) {
 10 | 	tests := []struct {
 11 | 		name     string
 12 | 		input    *models.PortainereeUser
 13 | 		expected User
 14 | 	}{
 15 | 		{
 16 | 			name: "admin user",
 17 | 			input: &models.PortainereeUser{
 18 | 				ID:       1,
 19 | 				Username: "admin",
 20 | 				Role:     1,
 21 | 			},
 22 | 			expected: User{
 23 | 				ID:       1,
 24 | 				Username: "admin",
 25 | 				Role:     UserRoleAdmin,
 26 | 			},
 27 | 		},
 28 | 		{
 29 | 			name: "regular user",
 30 | 			input: &models.PortainereeUser{
 31 | 				ID:       2,
 32 | 				Username: "user1",
 33 | 				Role:     2,
 34 | 			},
 35 | 			expected: User{
 36 | 				ID:       2,
 37 | 				Username: "user1",
 38 | 				Role:     UserRoleUser,
 39 | 			},
 40 | 		},
 41 | 		{
 42 | 			name: "edge admin user",
 43 | 			input: &models.PortainereeUser{
 44 | 				ID:       3,
 45 | 				Username: "edge_admin",
 46 | 				Role:     3,
 47 | 			},
 48 | 			expected: User{
 49 | 				ID:       3,
 50 | 				Username: "edge_admin",
 51 | 				Role:     UserRoleEdgeAdmin,
 52 | 			},
 53 | 		},
 54 | 	}
 55 | 
 56 | 	for _, tt := range tests {
 57 | 		t.Run(tt.name, func(t *testing.T) {
 58 | 			result := ConvertToUser(tt.input)
 59 | 			if result != tt.expected {
 60 | 				t.Errorf("ConvertToUser() = %v, want %v", result, tt.expected)
 61 | 			}
 62 | 		})
 63 | 	}
 64 | }
 65 | 
 66 | func TestConvertUserRole(t *testing.T) {
 67 | 	tests := []struct {
 68 | 		name     string
 69 | 		input    *models.PortainereeUser
 70 | 		expected string
 71 | 	}{
 72 | 		{
 73 | 			name:     "admin role",
 74 | 			input:    &models.PortainereeUser{Role: 1},
 75 | 			expected: UserRoleAdmin,
 76 | 		},
 77 | 		{
 78 | 			name:     "user role",
 79 | 			input:    &models.PortainereeUser{Role: 2},
 80 | 			expected: UserRoleUser,
 81 | 		},
 82 | 		{
 83 | 			name:     "edge admin role",
 84 | 			input:    &models.PortainereeUser{Role: 3},
 85 | 			expected: UserRoleEdgeAdmin,
 86 | 		},
 87 | 		{
 88 | 			name:     "unknown role",
 89 | 			input:    &models.PortainereeUser{Role: 999},
 90 | 			expected: UserRoleUnknown,
 91 | 		},
 92 | 	}
 93 | 
 94 | 	for _, tt := range tests {
 95 | 		t.Run(tt.name, func(t *testing.T) {
 96 | 			result := convertUserRole(tt.input)
 97 | 			if result != tt.expected {
 98 | 				t.Errorf("convertUserRole() = %v, want %v", result, tt.expected)
 99 | 			}
100 | 		})
101 | 	}
102 | }
103 | 
```

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

```markdown
 1 | # 202504-2: Strict versioning for tools.yaml file
 2 | 
 3 | **Date**: 08/04/2025
 4 | 
 5 | ### Context
 6 | With tools.yaml being externalized and allowing user customization, there's a risk of incompatibility between the tool definitions and the application code. Changes to the schema or expected tool definitions could lead to runtime errors that are difficult to diagnose.
 7 | 
 8 | ### Decision
 9 | Implement strict versioning for the tools.yaml file with version validation at startup. The application will define a required/current version, check if the provided tools.yaml file uses this version, and fail fast if there's a version mismatch.
10 | 
11 | ### Rationale
12 | 1. **Compatibility Assurance**
13 |    - Prevents runtime errors caused by incompatible tool definitions
14 |    - Clearly communicates version requirements to users
15 |    - Makes version mismatches immediately apparent
16 | 
17 | 2. **Error Handling**
18 |    - Provides clear error messages about version mismatches
19 |    - Fails fast instead of letting subtle errors occur during operation
20 |    - Guides users toward proper resolution
21 | 
22 | 3. **Recovery Path**
23 |    - Users can update their tools.yaml file manually to match the required version
24 |    - Alternatively, users can simply delete their customized file and let the application regenerate it
25 |    - Regeneration uses the embedded version which is guaranteed to be compatible
26 | 
27 | 4. **Upgrade Management**
28 |    - Clear versioning creates explicit upgrade paths
29 |    - Version checks provide a mechanism to enforce schema migrations
30 |    - Makes breaking changes in tool definitions more manageable
31 | 
32 | ### Trade-offs
33 | 
34 | **Benefits**
35 | - Prevents subtle runtime errors
36 | - Provides clear error messages
37 | - Offers straightforward recovery options
38 | - Makes version incompatibilities immediately apparent
39 | - Simplifies upgrade paths
40 | 
41 | **Challenges**
42 | - Need to manage version numbers across releases
43 | - Must communicate version changes to users
44 | - Requires additional validation logic at startup
45 | - Necessitates documentation of version compatibility
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"reflect"
 5 | 	"testing"
 6 | 
 7 | 	"github.com/portainer/client-api-go/v2/pkg/models"
 8 | )
 9 | 
10 | func TestConvertEndpointGroupToAccessGroup(t *testing.T) {
11 | 	tests := []struct {
12 | 		name     string
13 | 		group    *models.PortainerEndpointGroup
14 | 		envs     []*models.PortainereeEndpoint
15 | 		expected AccessGroup
16 | 	}{
17 | 		{
18 | 			name: "group with multiple environments and accesses",
19 | 			group: &models.PortainerEndpointGroup{
20 | 				ID:   1,
21 | 				Name: "Production",
22 | 				UserAccessPolicies: map[string]models.PortainerAccessPolicy{
23 | 					"1": {RoleID: 1},
24 | 					"2": {RoleID: 2},
25 | 				},
26 | 				TeamAccessPolicies: map[string]models.PortainerAccessPolicy{
27 | 					"10": {RoleID: 3},
28 | 					"20": {RoleID: 4},
29 | 				},
30 | 			},
31 | 			envs: []*models.PortainereeEndpoint{
32 | 				{ID: 100, GroupID: 1},
33 | 				{ID: 101, GroupID: 1},
34 | 				{ID: 102, GroupID: 2}, // Different group
35 | 			},
36 | 			expected: AccessGroup{
37 | 				ID:             1,
38 | 				Name:           "Production",
39 | 				EnvironmentIds: []int{100, 101},
40 | 				UserAccesses: map[int]string{
41 | 					1: "environment_administrator",
42 | 					2: "helpdesk_user",
43 | 				},
44 | 				TeamAccesses: map[int]string{
45 | 					10: "standard_user",
46 | 					20: "readonly_user",
47 | 				},
48 | 			},
49 | 		},
50 | 		{
51 | 			name: "group with no environments",
52 | 			group: &models.PortainerEndpointGroup{
53 | 				ID:   2,
54 | 				Name: "Empty",
55 | 				UserAccessPolicies: map[string]models.PortainerAccessPolicy{
56 | 					"1": {RoleID: 5},
57 | 				},
58 | 				TeamAccessPolicies: map[string]models.PortainerAccessPolicy{},
59 | 			},
60 | 			envs: []*models.PortainereeEndpoint{
61 | 				{ID: 100, GroupID: 1}, // Different group
62 | 			},
63 | 			expected: AccessGroup{
64 | 				ID:             2,
65 | 				Name:           "Empty",
66 | 				EnvironmentIds: []int{},
67 | 				UserAccesses: map[int]string{
68 | 					1: "operator_user",
69 | 				},
70 | 				TeamAccesses: map[int]string{},
71 | 			},
72 | 		},
73 | 	}
74 | 
75 | 	for _, tt := range tests {
76 | 		t.Run(tt.name, func(t *testing.T) {
77 | 			result := ConvertEndpointGroupToAccessGroup(tt.group, tt.envs)
78 | 
79 | 			if !reflect.DeepEqual(result, tt.expected) {
80 | 				t.Errorf("ConvertEndpointGroupToAccessGroup() = %v, want %v", result, tt.expected)
81 | 			}
82 | 		})
83 | 	}
84 | }
85 | 
```

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

```go
 1 | package integration
 2 | 
 3 | import (
 4 | 	"encoding/json"
 5 | 	"testing"
 6 | 
 7 | 	go_mcp "github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 9 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
10 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
11 | 	"github.com/stretchr/testify/assert"
12 | 	"github.com/stretchr/testify/require"
13 | )
14 | 
15 | // TestSettingsManagement is an integration test suite that verifies the retrieval
16 | // of Portainer settings via the MCP handler.
17 | func TestSettingsManagement(t *testing.T) {
18 | 	env := helpers.NewTestEnv(t)
19 | 	defer env.Cleanup(t)
20 | 
21 | 	// Subtest: Settings Retrieval
22 | 	// Verifies that:
23 | 	// - Settings can be correctly retrieved from the system via the MCP handler.
24 | 	// - The retrieved settings match the expected values after preparation.
25 | 	t.Run("Settings Retrieval", func(t *testing.T) {
26 | 		handler := env.MCPServer.HandleGetSettings()
27 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
28 | 		require.NoError(t, err, "Failed to get settings via MCP handler")
29 | 
30 | 		assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
31 | 		textContent, ok := result.Content[0].(go_mcp.TextContent)
32 | 		assert.True(t, ok, "Expected text content in response")
33 | 
34 | 		// Unmarshal the result from the MCP handler into the local models.PortainerSettings struct
35 | 		var retrievedSettings models.PortainerSettings
36 | 		err = json.Unmarshal([]byte(textContent.Text), &retrievedSettings)
37 | 		require.NoError(t, err, "Failed to unmarshal retrieved settings")
38 | 
39 | 		// Fetch settings directly via client to compare
40 | 		rawSettings, err := env.RawClient.GetSettings()
41 | 		require.NoError(t, err, "Failed to get settings directly via client for comparison")
42 | 
43 | 		// Convert the raw settings using the package's conversion function
44 | 		expectedConvertedSettings := models.ConvertSettingsToPortainerSettings(rawSettings)
45 | 
46 | 		// Compare the Settings struct from MCP handler with the one converted from the direct client call
47 | 		assert.Equal(t, expectedConvertedSettings, retrievedSettings, "Mismatch between MCP handler settings and converted client settings")
48 | 	})
49 | }
50 | 
```

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

```markdown
 1 | # 202503-2: Using tools to get resources instead of MCP resources
 2 | 
 3 | **Date**: 29/03/2025
 4 | 
 5 | ### Context
 6 | Initially, listing Portainer resources (environments, environment groups, stacks, etc.) was implemented using MCP resources. The project needed to evaluate whether this was the optimal approach given the current usage patterns and client constraints.
 7 | 
 8 | ### Decision
 9 | Replace MCP resources with tools for retrieving Portainer resources. For example, instead of exposing environments as MCP resources, provide a `listEnvironments` tool that the model can invoke.
10 | 
11 | ### Rationale
12 | 1. **Client Compatibility**
13 |    - Project currently relies on existing MCP clients (e.g., Claude Desktop)
14 |    - MCP resources require manual selection in these clients
15 |    - One-by-one resource selection creates friction in testing and iteration
16 | 
17 | 2. **Protocol Design Alignment**
18 |    - MCP resources are designed to be application-driven, requiring UI elements for selection
19 |    - Tools are designed to be model-controlled, better matching current use case
20 |    - Better alignment with the protocol's intended interaction patterns
21 | 
22 | 3. **User Experience**
23 |    - Models can directly request resource listings using natural language
24 |    - No need for manual resource selection in the client
25 |    - Faster iteration and testing cycles
26 | 
27 | 4. **Model Control**
28 |    - Tools provide a more direct interaction model for AI
29 |    - Models can determine when and what resources to list
30 |    - Approval flow is streamlined through tool invocation
31 | 
32 | ### Trade-offs
33 | 
34 | **Benefits**
35 | - Improved user experience through natural language requests
36 | - Faster testing and iteration cycles
37 | - Better alignment with existing client capabilities
38 | - More direct model control over resource access
39 | 
40 | **Challenges**
41 | - Potential loss of MCP resource-specific features
42 | - May need to reconsider if application-driven selection becomes necessary or when we'll need to build our own client
43 | 
44 | ### References
45 | - https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#user-interaction-model
46 | - https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#user-interaction-model
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"reflect"
 5 | 	"testing"
 6 | 
 7 | 	"github.com/portainer/client-api-go/v2/pkg/models"
 8 | )
 9 | 
10 | func TestConvertAccessPolicyRole(t *testing.T) {
11 | 	tests := []struct {
12 | 		name     string
13 | 		role     *models.PortainerAccessPolicy
14 | 		expected string
15 | 	}{
16 | 		{
17 | 			name:     "environment administrator role",
18 | 			role:     &models.PortainerAccessPolicy{RoleID: 1},
19 | 			expected: "environment_administrator",
20 | 		},
21 | 		{
22 | 			name:     "helpdesk user role",
23 | 			role:     &models.PortainerAccessPolicy{RoleID: 2},
24 | 			expected: "helpdesk_user",
25 | 		},
26 | 		{
27 | 			name:     "standard user role",
28 | 			role:     &models.PortainerAccessPolicy{RoleID: 3},
29 | 			expected: "standard_user",
30 | 		},
31 | 		{
32 | 			name:     "readonly user role",
33 | 			role:     &models.PortainerAccessPolicy{RoleID: 4},
34 | 			expected: "readonly_user",
35 | 		},
36 | 		{
37 | 			name:     "operator user role",
38 | 			role:     &models.PortainerAccessPolicy{RoleID: 5},
39 | 			expected: "operator_user",
40 | 		},
41 | 		{
42 | 			name:     "unknown role",
43 | 			role:     &models.PortainerAccessPolicy{RoleID: 999},
44 | 			expected: "unknown",
45 | 		},
46 | 	}
47 | 
48 | 	for _, tt := range tests {
49 | 		t.Run(tt.name, func(t *testing.T) {
50 | 			result := convertAccessPolicyRole(tt.role)
51 | 			if result != tt.expected {
52 | 				t.Errorf("convertAccessPolicyRole() = %v, want %v", result, tt.expected)
53 | 			}
54 | 		})
55 | 	}
56 | }
57 | 
58 | func TestConvertAccesses(t *testing.T) {
59 | 	t.Run("user accesses", func(t *testing.T) {
60 | 		policies := models.PortainerUserAccessPolicies{
61 | 			"1": models.PortainerAccessPolicy{RoleID: 1},
62 | 			"2": models.PortainerAccessPolicy{RoleID: 3},
63 | 		}
64 | 		expected := map[int]string{
65 | 			1: "environment_administrator",
66 | 			2: "standard_user",
67 | 		}
68 | 		result := convertAccesses(policies)
69 | 		if !reflect.DeepEqual(result, expected) {
70 | 			t.Errorf("convertAccesses() = %v, want %v", result, expected)
71 | 		}
72 | 	})
73 | 
74 | 	t.Run("team accesses", func(t *testing.T) {
75 | 		policies := models.PortainerTeamAccessPolicies{
76 | 			"10": models.PortainerAccessPolicy{RoleID: 1},
77 | 			"20": models.PortainerAccessPolicy{RoleID: 4},
78 | 		}
79 | 		expected := map[int]string{
80 | 			10: "environment_administrator",
81 | 			20: "readonly_user",
82 | 		}
83 | 		result := convertAccesses(policies)
84 | 		if !reflect.DeepEqual(result, expected) {
85 | 			t.Errorf("convertAccesses() = %v, want %v", result, expected)
86 | 		}
87 | 	})
88 | }
89 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"reflect"
 5 | 	"testing"
 6 | 
 7 | 	"github.com/portainer/client-api-go/v2/pkg/models"
 8 | )
 9 | 
10 | func TestConvertToTeam(t *testing.T) {
11 | 	tests := []struct {
12 | 		name         string
13 | 		team         *models.PortainerTeam
14 | 		memberships  []*models.PortainerTeamMembership
15 | 		expectedTeam Team
16 | 	}{
17 | 		{
18 | 			name: "team with multiple members",
19 | 			team: &models.PortainerTeam{
20 | 				ID:   1,
21 | 				Name: "DevOps",
22 | 			},
23 | 			memberships: []*models.PortainerTeamMembership{
24 | 				{TeamID: 1, UserID: 100},
25 | 				{TeamID: 1, UserID: 101},
26 | 				{TeamID: 1, UserID: 102},
27 | 				{TeamID: 2, UserID: 200}, // Different team, should be ignored
28 | 			},
29 | 			expectedTeam: Team{
30 | 				ID:        1,
31 | 				Name:      "DevOps",
32 | 				MemberIDs: []int{100, 101, 102},
33 | 			},
34 | 		},
35 | 		{
36 | 			name: "team with no members",
37 | 			team: &models.PortainerTeam{
38 | 				ID:   2,
39 | 				Name: "Empty Team",
40 | 			},
41 | 			memberships: []*models.PortainerTeamMembership{
42 | 				{TeamID: 1, UserID: 100}, // Different team
43 | 				{TeamID: 3, UserID: 300}, // Different team
44 | 			},
45 | 			expectedTeam: Team{
46 | 				ID:        2,
47 | 				Name:      "Empty Team",
48 | 				MemberIDs: []int{},
49 | 			},
50 | 		},
51 | 		{
52 | 			name: "team with single member",
53 | 			team: &models.PortainerTeam{
54 | 				ID:   3,
55 | 				Name: "Solo Team",
56 | 			},
57 | 			memberships: []*models.PortainerTeamMembership{
58 | 				{TeamID: 3, UserID: 300},
59 | 			},
60 | 			expectedTeam: Team{
61 | 				ID:        3,
62 | 				Name:      "Solo Team",
63 | 				MemberIDs: []int{300},
64 | 			},
65 | 		},
66 | 		{
67 | 			name: "team with empty memberships list",
68 | 			team: &models.PortainerTeam{
69 | 				ID:   4,
70 | 				Name: "New Team",
71 | 			},
72 | 			memberships: []*models.PortainerTeamMembership{},
73 | 			expectedTeam: Team{
74 | 				ID:        4,
75 | 				Name:      "New Team",
76 | 				MemberIDs: []int{},
77 | 			},
78 | 		},
79 | 	}
80 | 
81 | 	for _, tt := range tests {
82 | 		t.Run(tt.name, func(t *testing.T) {
83 | 			result := ConvertToTeam(tt.team, tt.memberships)
84 | 
85 | 			if result.ID != tt.expectedTeam.ID {
86 | 				t.Errorf("ID mismatch: got %v, want %v", result.ID, tt.expectedTeam.ID)
87 | 			}
88 | 
89 | 			if result.Name != tt.expectedTeam.Name {
90 | 				t.Errorf("Name mismatch: got %v, want %v", result.Name, tt.expectedTeam.Name)
91 | 			}
92 | 
93 | 			if !reflect.DeepEqual(result.MemberIDs, tt.expectedTeam.MemberIDs) {
94 | 				t.Errorf("MemberIDs mismatch: got %v, want %v", result.MemberIDs, tt.expectedTeam.MemberIDs)
95 | 			}
96 | 		})
97 | 	}
98 | }
99 | 
```

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

```markdown
 1 | # 202503-3: Specific tool for updates instead of a single update tool
 2 | 
 3 | **Date**: 29/03/2025
 4 | 
 5 | ### Context
 6 | Initially, resource updates (such as access groups, environments, etc.) were handled through single, multi-purpose update tools that could modify multiple properties at once. This approach led to complex parameter handling and unclear behavior around optional values.
 7 | 
 8 | ### Decision
 9 | Split update operations into multiple specific tools, each responsible for updating a single property or related set of properties. For example, instead of a single `updateAccessGroup` tool, create separate tools like:
10 | - `updateAccessGroupName`
11 | - `updateAccessGroupUserAccesses`
12 | - `updateAccessGroupTeamAccesses`
13 | 
14 | ### Rationale
15 | 1. **Parameter Clarity**
16 |    - Each tool has clear, required parameters
17 |    - No ambiguity between undefined parameters and empty values
18 |    - Eliminates need for complex optional parameter handling
19 | 
20 | 2. **Code Simplification**
21 |    - Removes need for pointer types in parameter handling
22 |    - Clearer validation of required parameters
23 |    - Simpler implementation of each specific update operation
24 | 
25 | 3. **Maintenance Benefits**
26 |    - Each tool has a single responsibility
27 |    - Easier to test individual update operations
28 |    - Clearer documentation of available operations
29 | 
30 | 4. **Model Interaction**
31 |    - Models can clearly understand which property they're updating
32 |    - More explicit about the changes being made
33 |    - Better alignment with natural language commands
34 | 
35 | ### Trade-offs
36 | 
37 | **Benefits**
38 | - Clearer parameter requirements and validation
39 | - Simpler code without pointer logic
40 | - Better separation of concerns
41 | - More explicit and focused tools
42 | - Easier testing and maintenance
43 | 
44 | **Challenges**
45 | - Multiple API calls needed for updating multiple properties
46 | - Slightly increased network traffic for multi-property updates
47 | - More tool definitions to maintain
48 | - No atomic updates across multiple properties
49 | - More tools might clutter the context of the model
50 | - Some clients have a hard limit on the number of tools that can be used/enabled
51 | 
52 | ### Notes
53 | Performance impact of multiple API calls is considered acceptable given:
54 | - Non-performance-critical context
55 | - Relatively low frequency of update operations
56 | - Benefits of simpler code and clearer behavior outweigh the overhead
```

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

```go
 1 | package main
 2 | 
 3 | import (
 4 | 	"flag"
 5 | 
 6 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 7 | 	"github.com/portainer/portainer-mcp/internal/tooldef"
 8 | 	"github.com/rs/zerolog/log"
 9 | )
10 | 
11 | const defaultToolsPath = "tools.yaml"
12 | 
13 | var (
14 | 	Version   string
15 | 	BuildDate string
16 | 	Commit    string
17 | )
18 | 
19 | func main() {
20 | 	log.Info().
21 | 		Str("version", Version).
22 | 		Str("build-date", BuildDate).
23 | 		Str("commit", Commit).
24 | 		Msg("Portainer MCP server")
25 | 
26 | 	serverFlag := flag.String("server", "", "The Portainer server URL")
27 | 	tokenFlag := flag.String("token", "", "The authentication token for the Portainer server")
28 | 	toolsFlag := flag.String("tools", "", "The path to the tools YAML file")
29 | 	readOnlyFlag := flag.Bool("read-only", false, "Run in read-only mode")
30 | 	disableVersionCheckFlag := flag.Bool("disable-version-check", false, "Disable Portainer server version check")
31 | 
32 | 	flag.Parse()
33 | 
34 | 	if *serverFlag == "" || *tokenFlag == "" {
35 | 		log.Fatal().Msg("Both -server and -token flags are required")
36 | 	}
37 | 
38 | 	toolsPath := *toolsFlag
39 | 	if toolsPath == "" {
40 | 		toolsPath = defaultToolsPath
41 | 	}
42 | 
43 | 	// We first check if the tools.yaml file exists
44 | 	// We'll create it from the embedded version if it doesn't exist
45 | 	exists, err := tooldef.CreateToolsFileIfNotExists(toolsPath)
46 | 	if err != nil {
47 | 		log.Fatal().Err(err).Msg("failed to create tools.yaml file")
48 | 	}
49 | 
50 | 	if exists {
51 | 		log.Info().Msg("using existing tools.yaml file")
52 | 	} else {
53 | 		log.Info().Msg("created tools.yaml file")
54 | 	}
55 | 
56 | 	log.Info().
57 | 		Str("portainer-host", *serverFlag).
58 | 		Str("tools-path", toolsPath).
59 | 		Bool("read-only", *readOnlyFlag).
60 | 		Bool("disable-version-check", *disableVersionCheckFlag).
61 | 		Msg("starting MCP server")
62 | 
63 | 	server, err := mcp.NewPortainerMCPServer(*serverFlag, *tokenFlag, toolsPath, mcp.WithReadOnly(*readOnlyFlag), mcp.WithDisableVersionCheck(*disableVersionCheckFlag))
64 | 	if err != nil {
65 | 		log.Fatal().Err(err).Msg("failed to create server")
66 | 	}
67 | 
68 | 	server.AddEnvironmentFeatures()
69 | 	server.AddEnvironmentGroupFeatures()
70 | 	server.AddTagFeatures()
71 | 	server.AddStackFeatures()
72 | 	server.AddSettingsFeatures()
73 | 	server.AddUserFeatures()
74 | 	server.AddTeamFeatures()
75 | 	server.AddAccessGroupFeatures()
76 | 	server.AddDockerProxyFeatures()
77 | 	server.AddKubernetesProxyFeatures()
78 | 
79 | 	err = server.Start()
80 | 	if err != nil {
81 | 		log.Fatal().Err(err).Msg("failed to start server")
82 | 	}
83 | }
84 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	"testing"
 5 | 	"time"
 6 | 
 7 | 	"reflect"
 8 | 
 9 | 	"github.com/portainer/client-api-go/v2/pkg/models"
10 | )
11 | 
12 | func TestConvertEdgeStackToStack(t *testing.T) {
13 | 	tests := []struct {
14 | 		name      string
15 | 		edgeStack *models.PortainereeEdgeStack
16 | 		want      Stack
17 | 	}{
18 | 		{
19 | 			name: "basic edge stack conversion",
20 | 			edgeStack: &models.PortainereeEdgeStack{
21 | 				ID:           1,
22 | 				Name:         "Web Application Stack",
23 | 				CreationDate: 1609459200, // 2021-01-01 00:00:00 UTC
24 | 				EdgeGroups:   []int64{1, 2, 3},
25 | 			},
26 | 			want: Stack{
27 | 				ID:                  1,
28 | 				Name:                "Web Application Stack",
29 | 				CreatedAt:           "2021-01-01T00:00:00Z",
30 | 				EnvironmentGroupIds: []int{1, 2, 3},
31 | 			},
32 | 		},
33 | 		{
34 | 			name: "edge stack with no groups",
35 | 			edgeStack: &models.PortainereeEdgeStack{
36 | 				ID:           2,
37 | 				Name:         "Empty Stack",
38 | 				CreationDate: 1640995200, // 2022-01-01 00:00:00 UTC
39 | 				EdgeGroups:   []int64{},
40 | 			},
41 | 			want: Stack{
42 | 				ID:                  2,
43 | 				Name:                "Empty Stack",
44 | 				CreatedAt:           "2022-01-01T00:00:00Z",
45 | 				EnvironmentGroupIds: []int{},
46 | 			},
47 | 		},
48 | 		{
49 | 			name: "edge stack with single group",
50 | 			edgeStack: &models.PortainereeEdgeStack{
51 | 				ID:           3,
52 | 				Name:         "Single Group Stack",
53 | 				CreationDate: 1672531200, // 2023-01-01 00:00:00 UTC
54 | 				EdgeGroups:   []int64{4},
55 | 			},
56 | 			want: Stack{
57 | 				ID:                  3,
58 | 				Name:                "Single Group Stack",
59 | 				CreatedAt:           "2023-01-01T00:00:00Z",
60 | 				EnvironmentGroupIds: []int{4},
61 | 			},
62 | 		},
63 | 		{
64 | 			name: "edge stack with current timestamp",
65 | 			edgeStack: &models.PortainereeEdgeStack{
66 | 				ID:           4,
67 | 				Name:         "Recent Stack",
68 | 				CreationDate: time.Now().Add(-24 * time.Hour).Unix(), // Yesterday
69 | 				EdgeGroups:   []int64{1, 2},
70 | 			},
71 | 			want: Stack{
72 | 				ID:                  4,
73 | 				Name:                "Recent Stack",
74 | 				CreatedAt:           time.Unix(time.Now().Add(-24*time.Hour).Unix(), 0).Format(time.RFC3339),
75 | 				EnvironmentGroupIds: []int{1, 2},
76 | 			},
77 | 		},
78 | 	}
79 | 
80 | 	for _, tt := range tests {
81 | 		t.Run(tt.name, func(t *testing.T) {
82 | 			got := ConvertEdgeStackToStack(tt.edgeStack)
83 | 			if !reflect.DeepEqual(got, tt.want) {
84 | 				t.Errorf("ConvertEdgeStackToStack() = %v, want %v", got, tt.want)
85 | 			}
86 | 		})
87 | 	}
88 | }
89 | 
```

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

```markdown
 1 | # Design Documentation Summary
 2 | 
 3 | This document provides a summary of key design decisions for the Portainer MCP project. Each decision is documented in detail in its own file.
 4 | 
 5 | ## Design Decisions
 6 | 
 7 | | ID | Title | Date | Description |
 8 | |----|-------|------|-------------|
 9 | | [202503-1](design/202503-1-external-tools-file.md) | Using an external tools file for tool definition | 29/03/2025 | Externalizes tool definitions into a YAML file for improved maintainability |
10 | | [202503-2](design/202503-2-tools-vs-mcp-resources.md) | Using tools to get resources instead of MCP resources | 29/03/2025 | Prefers tool-based resource access over MCP resources for better model control |
11 | | [202503-3](design/202503-3-specific-update-tools.md) | Specific tool for updates instead of a single update tool | 29/03/2025 | Splits update operations into specific tools for clearer parameter handling |
12 | | [202504-1](design/202504-1-embedded-tools-yaml.md) | Embedding tools.yaml in the binary | 08/04/2025 | Embeds the tools configuration file in the binary for simplified distribution |
13 | | [202504-2](design/202504-2-tools-yaml-versioning.md) | Strict versioning for tools.yaml file | 08/04/2025 | Implements versioning for tools.yaml to prevent compatibility issues |
14 | | [202504-3](design/202504-3-portainer-version-compatibility.md) | Pinning compatibility to a specific Portainer version | 08/04/2025 | Binds each release to a specific Portainer version for guaranteed compatibility |
15 | | [202504-4](design/202504-4-read-only-mode.md) | Read-only mode for enhanced security | 09/04/2025 | Provides a read-only mode to restrict modification capabilities for security |
16 | 
17 | ## How to Add a New Design Decision
18 | 
19 | 1. Create a new file in the `docs/design/` directory following the format:
20 |    - Filename: `YYYYMM-N-short-description.md` (e.g., `202505-1-feature-toggles.md`)
21 |    - Where `YYYYMM` is the date (year-month), and `N` is a sequence number for that date
22 | 
23 | 2. Use the standard template structure:
24 |    ```
25 |    # YYYYMM-N: Title
26 | 
27 |    **Date**: DD/MM/YYYY
28 | 
29 |    ### Context
30 |    [Background and reasons for this decision]
31 | 
32 |    ### Decision
33 |    [The decision that was made]
34 | 
35 |    ### Rationale
36 |    [Explanation of why this decision was made]
37 | 
38 |    ### Trade-offs
39 |    [Benefits and challenges of this approach]
40 |    ```
41 | 
42 | 3. Add the decision to the table in this summary document
```

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

```go
 1 | package mcp
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"encoding/json"
 6 | 	"testing"
 7 | 
 8 | 	"github.com/mark3labs/mcp-go/mcp"
 9 | 	"github.com/mark3labs/mcp-go/server"
10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
11 | 	"github.com/stretchr/testify/assert"
12 | )
13 | 
14 | func TestHandleGetSettings(t *testing.T) {
15 | 	tests := []struct {
16 | 		name          string
17 | 		settings      models.PortainerSettings
18 | 		mockError     error
19 | 		expectError   bool
20 | 		errorContains string
21 | 	}{
22 | 		{
23 | 			name: "successful settings retrieval",
24 | 			settings: models.PortainerSettings{
25 | 				Authentication: struct {
26 | 					Method string `json:"method"`
27 | 				}{
28 | 					Method: models.AuthenticationMethodInternal,
29 | 				},
30 | 				Edge: struct {
31 | 					Enabled   bool   `json:"enabled"`
32 | 					ServerURL string `json:"server_url"`
33 | 				}{
34 | 					Enabled:   true,
35 | 					ServerURL: "https://example.com",
36 | 				},
37 | 			},
38 | 			mockError:   nil,
39 | 			expectError: false,
40 | 		},
41 | 		{
42 | 			name:          "client error",
43 | 			settings:      models.PortainerSettings{},
44 | 			mockError:     assert.AnError,
45 | 			expectError:   true,
46 | 			errorContains: "failed to get settings",
47 | 		},
48 | 	}
49 | 
50 | 	for _, tt := range tests {
51 | 		t.Run(tt.name, func(t *testing.T) {
52 | 			// Create mock client
53 | 			mockClient := new(MockPortainerClient)
54 | 			mockClient.On("GetSettings").Return(tt.settings, tt.mockError)
55 | 
56 | 			// Create server with mock client
57 | 			srv := &PortainerMCPServer{
58 | 				srv:   server.NewMCPServer("Test Server", "1.0.0"),
59 | 				cli:   mockClient,
60 | 				tools: make(map[string]mcp.Tool),
61 | 			}
62 | 
63 | 			// Get the handler
64 | 			handler := srv.HandleGetSettings()
65 | 
66 | 			// Call the handler
67 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
68 | 
69 | 			if tt.expectError {
70 | 				assert.NoError(t, err)
71 | 				assert.NotNil(t, result)
72 | 				assert.True(t, result.IsError, "result.IsError should be true for API errors")
73 | 				assert.Len(t, result.Content, 1)
74 | 				textContent, ok := result.Content[0].(mcp.TextContent)
75 | 				assert.True(t, ok, "Result content should be mcp.TextContent")
76 | 				if tt.errorContains != "" {
77 | 					assert.Contains(t, textContent.Text, tt.errorContains)
78 | 				}
79 | 			} else {
80 | 				assert.NoError(t, err)
81 | 				assert.NotNil(t, result)
82 | 				assert.Len(t, result.Content, 1)
83 | 				textContent, ok := result.Content[0].(mcp.TextContent)
84 | 				assert.True(t, ok)
85 | 
86 | 				var settings models.PortainerSettings
87 | 				err = json.Unmarshal([]byte(textContent.Text), &settings)
88 | 				assert.NoError(t, err)
89 | 				assert.Equal(t, tt.settings, settings)
90 | 			}
91 | 
92 | 			// Verify mock expectations
93 | 			mockClient.AssertExpectations(t)
94 | 		})
95 | 	}
96 | }
97 | 
```

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

```markdown
 1 | # 202504-3: Pinning compatibility to a specific Portainer version
 2 | 
 3 | **Date**: 08/04/2025
 4 | 
 5 | ### Context
 6 | Portainer server does not implement API versioning, making it challenging to ensure compatibility between our software and different Portainer server versions. Each version of Portainer may have different API behaviors and endpoints, which could cause runtime errors or unexpected behavior if not properly managed.
 7 | 
 8 | ### Decision
 9 | Maintain independent versioning for this software while explicitly pinning compatibility to a specific Portainer server version. The software will validate the Portainer server version at startup and fail fast if the detected version does not match the required version exactly. Documentation will clearly indicate which exact Portainer version is supported by each software release.
10 | 
11 | ### Rationale
12 | 1. **Independent Release Cycle**
13 |    - Software can be updated outside of the Portainer release lifecycle
14 |    - Allows for bug fixes and features without waiting for Portainer releases
15 |    - Enables more frequent iterations and improvements
16 | 
17 | 2. **Exact Compatibility**
18 |    - Each release will document the specific Portainer version it supports
19 |    - Strict version checking at startup prevents compatibility issues
20 |    - Ensures 100% compatibility with the supported API endpoints
21 | 
22 | 3. **SDK Alignment**
23 |    - Software will use a Go SDK version that matches exactly the supported Portainer version
24 |    - Creates a precise binding between SDK capabilities and software functionality
25 |    - Eliminates ambiguity about supported functionality
26 | 
27 | 4. **Error Prevention**
28 |    - Early validation of the exact Portainer version prevents any API compatibility issues
29 |    - Users receive clear error messages when the version doesn't match
30 |    - Completely eliminates support requests related to API incompatibilities
31 | 
32 | ### Trade-offs
33 | 
34 | **Benefits**
35 | - Flexible release schedule independent of Portainer
36 | - Absolute certainty about compatibility requirements
37 | - Fail-fast behavior for unsupported versions
38 | - Predictable behavior with supported Portainer version
39 | - Simplified testing against a single Portainer version
40 | 
41 | **Challenges**
42 | - Users must upgrade/downgrade Portainer to the exact supported version
43 | - Each software release requires a new version when supporting a new Portainer version
44 | - More restrictive for users who can't easily change their Portainer version
45 | - Overhead of version validation at startup
46 | - Need to clearly communicate the exact supported version in all documentation
```

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

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"testing"
  6 | 
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  9 | 	"github.com/stretchr/testify/assert"
 10 | )
 11 | 
 12 | func TestGetEnvironmentTags(t *testing.T) {
 13 | 	tests := []struct {
 14 | 		name          string
 15 | 		mockTags      []*apimodels.PortainerTag
 16 | 		mockError     error
 17 | 		expectedTags  []models.EnvironmentTag
 18 | 		expectedError bool
 19 | 	}{
 20 | 		{
 21 | 			name: "successful retrieval",
 22 | 			mockTags: []*apimodels.PortainerTag{
 23 | 				{ID: 1, Name: "prod"},
 24 | 				{ID: 2, Name: "dev"},
 25 | 			},
 26 | 			mockError: nil,
 27 | 			expectedTags: []models.EnvironmentTag{
 28 | 				{ID: 1, Name: "prod", EnvironmentIds: []int{}},
 29 | 				{ID: 2, Name: "dev", EnvironmentIds: []int{}},
 30 | 			},
 31 | 			expectedError: false,
 32 | 		},
 33 | 		{
 34 | 			name:          "empty tags list",
 35 | 			mockTags:      []*apimodels.PortainerTag{},
 36 | 			mockError:     nil,
 37 | 			expectedTags:  []models.EnvironmentTag{},
 38 | 			expectedError: false,
 39 | 		},
 40 | 		{
 41 | 			name:          "api error",
 42 | 			mockTags:      nil,
 43 | 			mockError:     fmt.Errorf("api error"),
 44 | 			expectedTags:  nil,
 45 | 			expectedError: true,
 46 | 		},
 47 | 	}
 48 | 
 49 | 	for _, tt := range tests {
 50 | 		t.Run(tt.name, func(t *testing.T) {
 51 | 			mockAPI := new(MockPortainerAPI)
 52 | 			mockAPI.On("ListTags").Return(tt.mockTags, tt.mockError)
 53 | 
 54 | 			client := &PortainerClient{
 55 | 				cli: mockAPI,
 56 | 			}
 57 | 
 58 | 			tags, err := client.GetEnvironmentTags()
 59 | 
 60 | 			if tt.expectedError {
 61 | 				assert.Error(t, err)
 62 | 			} else {
 63 | 				assert.NoError(t, err)
 64 | 				assert.Equal(t, tt.expectedTags, tags)
 65 | 			}
 66 | 
 67 | 			mockAPI.AssertExpectations(t)
 68 | 		})
 69 | 	}
 70 | }
 71 | 
 72 | func TestCreateEnvironmentTag(t *testing.T) {
 73 | 	tests := []struct {
 74 | 		name          string
 75 | 		tagName       string
 76 | 		mockID        int64
 77 | 		mockError     error
 78 | 		expectedID    int
 79 | 		expectedError bool
 80 | 	}{
 81 | 		{
 82 | 			name:          "successful creation",
 83 | 			tagName:       "prod",
 84 | 			mockID:        1,
 85 | 			mockError:     nil,
 86 | 			expectedID:    1,
 87 | 			expectedError: false,
 88 | 		},
 89 | 		{
 90 | 			name:          "api error",
 91 | 			tagName:       "dev",
 92 | 			mockID:        0,
 93 | 			mockError:     fmt.Errorf("api error"),
 94 | 			expectedID:    0,
 95 | 			expectedError: true,
 96 | 		},
 97 | 	}
 98 | 
 99 | 	for _, tt := range tests {
100 | 		t.Run(tt.name, func(t *testing.T) {
101 | 			mockAPI := new(MockPortainerAPI)
102 | 			mockAPI.On("CreateTag", tt.tagName).Return(tt.mockID, tt.mockError)
103 | 
104 | 			client := &PortainerClient{
105 | 				cli: mockAPI,
106 | 			}
107 | 
108 | 			id, err := client.CreateEnvironmentTag(tt.tagName)
109 | 
110 | 			if tt.expectedError {
111 | 				assert.Error(t, err)
112 | 			} else {
113 | 				assert.NoError(t, err)
114 | 				assert.Equal(t, tt.expectedID, id)
115 | 			}
116 | 
117 | 			mockAPI.AssertExpectations(t)
118 | 		})
119 | 	}
120 | }
121 | 
```

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

```go
 1 | package tooldef
 2 | 
 3 | import (
 4 | 	"os"
 5 | 	"path/filepath"
 6 | 	"testing"
 7 | 
 8 | 	"github.com/stretchr/testify/assert"
 9 | 	"github.com/stretchr/testify/require"
10 | )
11 | 
12 | func TestCreateToolsFileIfNotExists(t *testing.T) {
13 | 	// Create a temporary directory for testing
14 | 	tempDir, err := os.MkdirTemp("", "tooldef-test")
15 | 	require.NoError(t, err, "Failed to create temporary directory")
16 | 	defer os.RemoveAll(tempDir)
17 | 
18 | 	t.Run("File Does Not Exist", func(t *testing.T) {
19 | 		// Define a path for a non-existent file
20 | 		filePath := filepath.Join(tempDir, "new-tools.yaml")
21 | 
22 | 		// Verify the file doesn't exist initially
23 | 		_, err := os.Stat(filePath)
24 | 		assert.True(t, os.IsNotExist(err), "File should not exist before test")
25 | 
26 | 		exists, err := CreateToolsFileIfNotExists(filePath)
27 | 		require.NoError(t, err, "Function should not return an error")
28 | 		assert.False(t, exists, "Function should return false when creating a new file")
29 | 
30 | 		// Verify the file was created
31 | 		_, err = os.Stat(filePath)
32 | 		assert.NoError(t, err, "File should exist after function call")
33 | 
34 | 		// Verify the file has the embedded content
35 | 		content, err := os.ReadFile(filePath)
36 | 		require.NoError(t, err, "Should be able to read the created file")
37 | 		assert.Equal(t, ToolsFile, content, "File should contain the embedded tools content")
38 | 	})
39 | 
40 | 	t.Run("File Already Exists", func(t *testing.T) {
41 | 		// Define a path for an existing file
42 | 		filePath := filepath.Join(tempDir, "existing-tools.yaml")
43 | 
44 | 		// Create a custom file
45 | 		customContent := []byte("# Custom tools file content")
46 | 		err := os.WriteFile(filePath, customContent, 0644)
47 | 		require.NoError(t, err, "Failed to create test file")
48 | 
49 | 		exists, err := CreateToolsFileIfNotExists(filePath)
50 | 		require.NoError(t, err, "Function should not return an error")
51 | 		assert.True(t, exists, "Function should return true when file already exists")
52 | 
53 | 		// Verify the file content was not changed
54 | 		content, err := os.ReadFile(filePath)
55 | 		require.NoError(t, err, "Should be able to read the existing file")
56 | 		assert.Equal(t, customContent, content, "Function should not modify an existing file")
57 | 	})
58 | 
59 | 	t.Run("Error During File Creation", func(t *testing.T) {
60 | 		// Create a path in a non-existent directory to force a file creation error
61 | 		nonExistentDir := filepath.Join(tempDir, "this-directory-does-not-exist", "neither-does-this-one")
62 | 		filePath := filepath.Join(nonExistentDir, "tools.yaml")
63 | 
64 | 		exists, err := CreateToolsFileIfNotExists(filePath)
65 | 		assert.Error(t, err, "Function should return an error when file creation fails")
66 | 		assert.False(t, exists, "Function should return false when an error occurs")
67 | 	})
68 | }
69 | 
```

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

```markdown
 1 | # 202504-4: Read-only mode for enhanced security
 2 | 
 3 | **Date**: 09/04/2025
 4 | 
 5 | ### Context
 6 | Model Context Protocol (MCP) is a relatively new technology with varying levels of trust among infrastructure operators. There are significant concerns about potential security risks when allowing AI models to modify production resources. These concerns are heightened by the growing awareness of prompt injection attacks, model hallucinations, and other LLM-specific vulnerabilities that could be exploited to trigger unintended operations on critical infrastructure. Portainer often manages production container environments, making the security implications particularly serious.
 7 | 
 8 | ### Decision
 9 | Implement a read-only flag that can be specified at application startup. When this flag is enabled, the application will only register and expose read-oriented tools, completely omitting any tools capable of modifying Portainer resources.
10 | 
11 | ### Rationale
12 | 1. **Security Enhancement**
13 |    - Eliminates risk of accidental or unauthorized modifications to production environments
14 |    - Provides a safe mode for users to explore and monitor without modification capabilities
15 |    - Creates a clear separation between monitoring and management use cases
16 | 
17 | 2. **Operational Safety**
18 |    - Enables safe usage in sensitive production environments
19 |    - Reduces potential impact of prompt injection or model hallucination issues
20 |    - Provides an additional layer of protection for critical infrastructure
21 | 
22 | 3. **User Trust**
23 |    - Addresses concerns of security-conscious users about potential write implications
24 |    - Creates confidence that the application cannot modify resources when in read-only mode
25 |    - Offers a path for skeptical users to start with limited capabilities before enabling full functionality
26 | 
27 | 4. **Use Case Alignment**
28 |    - Matches common use case of "explore first, modify later" workflow
29 |    - Supports read-only scenarios like monitoring, auditing, and documentation
30 |    - Creates a clear distinction between observability and management roles
31 | 
32 | ### Trade-offs
33 | 
34 | **Benefits**
35 | - Enhanced security posture for sensitive environments
36 | - Reduced risk surface for production deployments
37 | - Builds user trust through clear capability boundaries
38 | - Better alignment with specific read-only use cases
39 | - Allows progressive adoption starting with read-only mode
40 | 
41 | **Challenges**
42 | - Need to categorize tools as read or write operations
43 | - Additional startup mode to test and maintain
44 | - Potential user confusion about available capabilities in each mode
45 | - May require switching between modes for different workflows
46 | - Reduced functionality in read-only mode may limit some complex scenarios
```

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

```go
  1 | package models
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/portainer/client-api-go/v2/pkg/models"
  7 | )
  8 | 
  9 | func TestConvertTagToEnvironmentTag(t *testing.T) {
 10 | 	tests := []struct {
 11 | 		name         string
 12 | 		portainerTag *models.PortainerTag
 13 | 		want         EnvironmentTag
 14 | 	}{
 15 | 		{
 16 | 			name: "basic tag conversion",
 17 | 			portainerTag: &models.PortainerTag{
 18 | 				ID:   1,
 19 | 				Name: "Production",
 20 | 				Endpoints: map[string]bool{
 21 | 					"1": true,
 22 | 					"2": true,
 23 | 					"3": true,
 24 | 				},
 25 | 			},
 26 | 			want: EnvironmentTag{
 27 | 				ID:             1,
 28 | 				Name:           "Production",
 29 | 				EnvironmentIds: []int{1, 2, 3},
 30 | 			},
 31 | 		},
 32 | 		{
 33 | 			name: "tag with no endpoints",
 34 | 			portainerTag: &models.PortainerTag{
 35 | 				ID:        2,
 36 | 				Name:      "Empty Tag",
 37 | 				Endpoints: map[string]bool{},
 38 | 			},
 39 | 			want: EnvironmentTag{
 40 | 				ID:             2,
 41 | 				Name:           "Empty Tag",
 42 | 				EnvironmentIds: []int{},
 43 | 			},
 44 | 		},
 45 | 		{
 46 | 			name: "tag with invalid endpoint ID",
 47 | 			portainerTag: &models.PortainerTag{
 48 | 				ID:   3,
 49 | 				Name: "Mixed IDs",
 50 | 				Endpoints: map[string]bool{
 51 | 					"42":      true,
 52 | 					"abc":     true, // Invalid ID, should be skipped
 53 | 					"99":      true,
 54 | 					"invalid": true, // Invalid ID, should be skipped
 55 | 				},
 56 | 			},
 57 | 			want: EnvironmentTag{
 58 | 				ID:             3,
 59 | 				Name:           "Mixed IDs",
 60 | 				EnvironmentIds: []int{42, 99},
 61 | 			},
 62 | 		},
 63 | 		{
 64 | 			name: "tag with single endpoint",
 65 | 			portainerTag: &models.PortainerTag{
 66 | 				ID:   4,
 67 | 				Name: "Single Server",
 68 | 				Endpoints: map[string]bool{
 69 | 					"5": true,
 70 | 				},
 71 | 			},
 72 | 			want: EnvironmentTag{
 73 | 				ID:             4,
 74 | 				Name:           "Single Server",
 75 | 				EnvironmentIds: []int{5},
 76 | 			},
 77 | 		},
 78 | 	}
 79 | 
 80 | 	for _, tt := range tests {
 81 | 		t.Run(tt.name, func(t *testing.T) {
 82 | 			got := ConvertTagToEnvironmentTag(tt.portainerTag)
 83 | 
 84 | 			// Since the order of EnvironmentIds is not guaranteed due to map iteration,
 85 | 			// we need to sort both slices before comparison
 86 | 			if !compareEnvironmentTags(got, tt.want) {
 87 | 				t.Errorf("ConvertTagToEnvironmentTag() = %v, want %v", got, tt.want)
 88 | 			}
 89 | 		})
 90 | 	}
 91 | }
 92 | 
 93 | // compareEnvironmentTags compares two EnvironmentTag structs, handling the
 94 | // unordered nature of the EnvironmentIds slice
 95 | func compareEnvironmentTags(a, b EnvironmentTag) bool {
 96 | 	if a.ID != b.ID || a.Name != b.Name || len(a.EnvironmentIds) != len(b.EnvironmentIds) {
 97 | 		return false
 98 | 	}
 99 | 
100 | 	// Create maps to check if all IDs exist in both slices
101 | 	aMap := make(map[int]bool)
102 | 	bMap := make(map[int]bool)
103 | 
104 | 	for _, id := range a.EnvironmentIds {
105 | 		aMap[id] = true
106 | 	}
107 | 
108 | 	for _, id := range b.EnvironmentIds {
109 | 		bMap[id] = true
110 | 		if !aMap[id] {
111 | 			return false
112 | 		}
113 | 	}
114 | 
115 | 	// Check if all IDs in a exist in b
116 | 	for id := range aMap {
117 | 		if !bMap[id] {
118 | 			return false
119 | 		}
120 | 	}
121 | 
122 | 	return true
123 | }
124 | 
```

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

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 
  6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  7 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
  8 | )
  9 | 
 10 | // GetEnvironments retrieves all environments from the Portainer server.
 11 | //
 12 | // Returns:
 13 | //   - A slice of Environment objects
 14 | //   - An error if the operation fails
 15 | func (c *PortainerClient) GetEnvironments() ([]models.Environment, error) {
 16 | 	endpoints, err := c.cli.ListEndpoints()
 17 | 	if err != nil {
 18 | 		return nil, fmt.Errorf("failed to list endpoints: %w", err)
 19 | 	}
 20 | 
 21 | 	environments := make([]models.Environment, len(endpoints))
 22 | 	for i, endpoint := range endpoints {
 23 | 		environments[i] = models.ConvertEndpointToEnvironment(endpoint)
 24 | 	}
 25 | 
 26 | 	return environments, nil
 27 | }
 28 | 
 29 | // UpdateEnvironmentTags updates the tags associated with an environment.
 30 | //
 31 | // Parameters:
 32 | //   - id: The ID of the environment to update
 33 | //   - tagIds: A slice of tag IDs to associate with the environment
 34 | //
 35 | // Returns:
 36 | //   - An error if the operation fails
 37 | func (c *PortainerClient) UpdateEnvironmentTags(id int, tagIds []int) error {
 38 | 	tags := utils.IntToInt64Slice(tagIds)
 39 | 	err := c.cli.UpdateEndpoint(int64(id),
 40 | 		&tags,
 41 | 		nil,
 42 | 		nil,
 43 | 	)
 44 | 	if err != nil {
 45 | 		return fmt.Errorf("failed to update environment tags: %w", err)
 46 | 	}
 47 | 	return nil
 48 | }
 49 | 
 50 | // UpdateEnvironmentUserAccesses updates the user access policies of an environment.
 51 | //
 52 | // Parameters:
 53 | //   - id: The ID of the environment to update
 54 | //   - userAccesses: Map of user IDs to their access level
 55 | //
 56 | // Valid access levels are:
 57 | //   - environment_administrator
 58 | //   - helpdesk_user
 59 | //   - standard_user
 60 | //   - readonly_user
 61 | //   - operator_user
 62 | //
 63 | // Returns:
 64 | //   - An error if the operation fails
 65 | func (c *PortainerClient) UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error {
 66 | 	uac := utils.IntToInt64Map(userAccesses)
 67 | 	err := c.cli.UpdateEndpoint(int64(id),
 68 | 		nil,
 69 | 		&uac,
 70 | 		nil,
 71 | 	)
 72 | 	if err != nil {
 73 | 		return fmt.Errorf("failed to update environment user accesses: %w", err)
 74 | 	}
 75 | 	return nil
 76 | }
 77 | 
 78 | // UpdateEnvironmentTeamAccesses updates the team access policies of an environment.
 79 | //
 80 | // Parameters:
 81 | //   - id: The ID of the environment to update
 82 | //   - teamAccesses: Map of team IDs to their access level
 83 | //
 84 | // Valid access levels are:
 85 | //   - environment_administrator
 86 | //   - helpdesk_user
 87 | //   - standard_user
 88 | //   - readonly_user
 89 | //   - operator_user
 90 | //
 91 | // Returns:
 92 | //   - An error if the operation fails
 93 | func (c *PortainerClient) UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error {
 94 | 	tac := utils.IntToInt64Map(teamAccesses)
 95 | 	err := c.cli.UpdateEndpoint(int64(id),
 96 | 		nil,
 97 | 		nil,
 98 | 		&tac,
 99 | 	)
100 | 	if err != nil {
101 | 		return fmt.Errorf("failed to update environment team accesses: %w", err)
102 | 	}
103 | 	return nil
104 | }
105 | 
```

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

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 
 6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 7 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
 8 | )
 9 | 
10 | // GetStacks retrieves all stacks from the Portainer server.
11 | // Stacks are the equivalent of Edge Stacks in Portainer.
12 | //
13 | // Returns:
14 | //   - A slice of Stack objects
15 | //   - An error if the operation fails
16 | func (c *PortainerClient) GetStacks() ([]models.Stack, error) {
17 | 	edgeStacks, err := c.cli.ListEdgeStacks()
18 | 	if err != nil {
19 | 		return nil, fmt.Errorf("failed to list edge stacks: %w", err)
20 | 	}
21 | 
22 | 	stacks := make([]models.Stack, len(edgeStacks))
23 | 	for i, es := range edgeStacks {
24 | 		stacks[i] = models.ConvertEdgeStackToStack(es)
25 | 	}
26 | 
27 | 	return stacks, nil
28 | }
29 | 
30 | // GetStackFile retrieves the file content of a stack from the Portainer server.
31 | // Stacks are the equivalent of Edge Stacks in Portainer.
32 | //
33 | // Parameters:
34 | //   - id: The ID of the stack to retrieve
35 | //
36 | // Returns:
37 | //   - The file content of the stack (Compose file)
38 | //   - An error if the operation fails
39 | func (c *PortainerClient) GetStackFile(id int) (string, error) {
40 | 	file, err := c.cli.GetEdgeStackFile(int64(id))
41 | 	if err != nil {
42 | 		return "", fmt.Errorf("failed to get edge stack file: %w", err)
43 | 	}
44 | 
45 | 	return file, nil
46 | }
47 | 
48 | // CreateStack creates a new stack on the Portainer server.
49 | // This function specifically creates a Docker Compose stack.
50 | // Stacks are the equivalent of Edge Stacks in Portainer.
51 | //
52 | // Parameters:
53 | //   - name: The name of the stack
54 | //   - file: The file content of the stack (Compose file)
55 | //   - environmentGroupIds: A slice of environment group IDs to include in the stack
56 | //
57 | // Returns:
58 | //   - The ID of the created stack
59 | //   - An error if the operation fails
60 | func (c *PortainerClient) CreateStack(name, file string, environmentGroupIds []int) (int, error) {
61 | 	id, err := c.cli.CreateEdgeStack(name, file, utils.IntToInt64Slice(environmentGroupIds))
62 | 	if err != nil {
63 | 		return 0, fmt.Errorf("failed to create edge stack: %w", err)
64 | 	}
65 | 
66 | 	return int(id), nil
67 | }
68 | 
69 | // UpdateStack updates an existing stack on the Portainer server.
70 | // This function specifically updates a Docker Compose stack.
71 | // Stacks are the equivalent of Edge Stacks in Portainer.
72 | //
73 | // Parameters:
74 | //   - id: The ID of the stack to update
75 | //   - file: The file content of the stack (Compose file)
76 | //   - environmentGroupIds: A slice of environment group IDs to include in the stack
77 | //
78 | // Returns:
79 | //   - An error if the operation fails
80 | func (c *PortainerClient) UpdateStack(id int, file string, environmentGroupIds []int) error {
81 | 	err := c.cli.UpdateEdgeStack(int64(id), file, utils.IntToInt64Slice(environmentGroupIds))
82 | 	if err != nil {
83 | 		return fmt.Errorf("failed to update edge stack: %w", err)
84 | 	}
85 | 
86 | 	return nil
87 | }
88 | 
```

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

```go
 1 | package mcp
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 	"io"
 7 | 	"strings"
 8 | 
 9 | 	"github.com/mark3labs/mcp-go/mcp"
10 | 	"github.com/mark3labs/mcp-go/server"
11 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
12 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
13 | )
14 | 
15 | func (s *PortainerMCPServer) AddDockerProxyFeatures() {
16 | 	if !s.readOnly {
17 | 		s.addToolIfExists(ToolDockerProxy, s.HandleDockerProxy())
18 | 	}
19 | }
20 | 
21 | func (s *PortainerMCPServer) HandleDockerProxy() server.ToolHandlerFunc {
22 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
23 | 		parser := toolgen.NewParameterParser(request)
24 | 
25 | 		environmentId, err := parser.GetInt("environmentId", true)
26 | 		if err != nil {
27 | 			return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
28 | 		}
29 | 
30 | 		method, err := parser.GetString("method", true)
31 | 		if err != nil {
32 | 			return mcp.NewToolResultErrorFromErr("invalid method parameter", err), nil
33 | 		}
34 | 		if !isValidHTTPMethod(method) {
35 | 			return mcp.NewToolResultError(fmt.Sprintf("invalid method: %s", method)), nil
36 | 		}
37 | 
38 | 		dockerAPIPath, err := parser.GetString("dockerAPIPath", true)
39 | 		if err != nil {
40 | 			return mcp.NewToolResultErrorFromErr("invalid dockerAPIPath parameter", err), nil
41 | 		}
42 | 		if !strings.HasPrefix(dockerAPIPath, "/") {
43 | 			return mcp.NewToolResultError("dockerAPIPath must start with a leading slash"), nil
44 | 		}
45 | 
46 | 		queryParams, err := parser.GetArrayOfObjects("queryParams", false)
47 | 		if err != nil {
48 | 			return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
49 | 		}
50 | 		queryParamsMap, err := parseKeyValueMap(queryParams)
51 | 		if err != nil {
52 | 			return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
53 | 		}
54 | 
55 | 		headers, err := parser.GetArrayOfObjects("headers", false)
56 | 		if err != nil {
57 | 			return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
58 | 		}
59 | 		headersMap, err := parseKeyValueMap(headers)
60 | 		if err != nil {
61 | 			return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
62 | 		}
63 | 
64 | 		body, err := parser.GetString("body", false)
65 | 		if err != nil {
66 | 			return mcp.NewToolResultErrorFromErr("invalid body parameter", err), nil
67 | 		}
68 | 
69 | 		opts := models.DockerProxyRequestOptions{
70 | 			EnvironmentID: environmentId,
71 | 			Path:          dockerAPIPath,
72 | 			Method:        method,
73 | 			QueryParams:   queryParamsMap,
74 | 			Headers:       headersMap,
75 | 		}
76 | 
77 | 		if body != "" {
78 | 			opts.Body = strings.NewReader(body)
79 | 		}
80 | 
81 | 		response, err := s.cli.ProxyDockerRequest(opts)
82 | 		if err != nil {
83 | 			return mcp.NewToolResultErrorFromErr("failed to send Docker API request", err), nil
84 | 		}
85 | 
86 | 		responseBody, err := io.ReadAll(response.Body)
87 | 		if err != nil {
88 | 			return mcp.NewToolResultErrorFromErr("failed to read Docker API response", err), nil
89 | 		}
90 | 
91 | 		return mcp.NewToolResultText(string(responseBody)), nil
92 | 	}
93 | }
94 | 
```

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

```go
 1 | package models
 2 | 
 3 | import (
 4 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 5 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
 6 | )
 7 | 
 8 | type Environment struct {
 9 | 	ID           int            `json:"id"`
10 | 	Name         string         `json:"name"`
11 | 	Status       string         `json:"status"`
12 | 	Type         string         `json:"type"`
13 | 	TagIds       []int          `json:"tag_ids"`
14 | 	UserAccesses map[int]string `json:"user_accesses"`
15 | 	TeamAccesses map[int]string `json:"team_accesses"`
16 | }
17 | 
18 | // Environment status constants
19 | const (
20 | 	EnvironmentStatusActive   = "active"
21 | 	EnvironmentStatusInactive = "inactive"
22 | 	EnvironmentStatusUnknown  = "unknown"
23 | )
24 | 
25 | // Environment type constants
26 | const (
27 | 	EnvironmentTypeDockerLocal         = "docker-local"
28 | 	EnvironmentTypeDockerAgent         = "docker-agent"
29 | 	EnvironmentTypeAzureACI            = "azure-aci"
30 | 	EnvironmentTypeDockerEdgeAgent     = "docker-edge-agent"
31 | 	EnvironmentTypeKubernetesLocal     = "kubernetes-local"
32 | 	EnvironmentTypeKubernetesAgent     = "kubernetes-agent"
33 | 	EnvironmentTypeKubernetesEdgeAgent = "kubernetes-edge-agent"
34 | 	EnvironmentTypeUnknown             = "unknown"
35 | )
36 | 
37 | func ConvertEndpointToEnvironment(rawEndpoint *apimodels.PortainereeEndpoint) Environment {
38 | 	return Environment{
39 | 		ID:           int(rawEndpoint.ID),
40 | 		Name:         rawEndpoint.Name,
41 | 		Status:       convertEnvironmentStatus(rawEndpoint),
42 | 		Type:         convertEnvironmentType(rawEndpoint),
43 | 		TagIds:       utils.Int64ToIntSlice(rawEndpoint.TagIds),
44 | 		UserAccesses: convertAccesses(rawEndpoint.UserAccessPolicies),
45 | 		TeamAccesses: convertAccesses(rawEndpoint.TeamAccessPolicies),
46 | 	}
47 | }
48 | 
49 | func convertEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string {
50 | 	if rawEndpoint.Type == 4 || rawEndpoint.Type == 7 {
51 | 		return convertEdgeEnvironmentStatus(rawEndpoint)
52 | 	}
53 | 	return convertStandardEnvironmentStatus(rawEndpoint)
54 | }
55 | 
56 | func convertStandardEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string {
57 | 	switch rawEndpoint.Status {
58 | 	case 1:
59 | 		return EnvironmentStatusActive
60 | 	case 2:
61 | 		return EnvironmentStatusInactive
62 | 	default:
63 | 		return EnvironmentStatusUnknown
64 | 	}
65 | }
66 | 
67 | func convertEdgeEnvironmentStatus(rawEndpoint *apimodels.PortainereeEndpoint) string {
68 | 	if rawEndpoint.Heartbeat {
69 | 		return EnvironmentStatusActive
70 | 	}
71 | 	return EnvironmentStatusInactive
72 | }
73 | 
74 | func convertEnvironmentType(rawEndpoint *apimodels.PortainereeEndpoint) string {
75 | 	switch rawEndpoint.Type {
76 | 	case 1:
77 | 		return EnvironmentTypeDockerLocal
78 | 	case 2:
79 | 		return EnvironmentTypeDockerAgent
80 | 	case 3:
81 | 		return EnvironmentTypeAzureACI
82 | 	case 4:
83 | 		return EnvironmentTypeDockerEdgeAgent
84 | 	case 5:
85 | 		return EnvironmentTypeKubernetesLocal
86 | 	case 6:
87 | 		return EnvironmentTypeKubernetesAgent
88 | 	case 7:
89 | 		return EnvironmentTypeKubernetesEdgeAgent
90 | 	default:
91 | 		return EnvironmentTypeUnknown
92 | 	}
93 | }
94 | 
```

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

```bash
 1 | #!/bin/bash
 2 | 
 3 | # This script calculates the estimated token count for a given set of tools
 4 | # and a sample message using the Anthropic API's /v1/messages/count_tokens endpoint.
 5 | # It reads tool definitions from a JSON file generated by the `cmd/token-count` Go program.
 6 | #
 7 | # Requires:
 8 | #   - curl : For making HTTP requests to the Anthropic API.
 9 | #   - jq   : For constructing the JSON payload from the input file.
10 | #
11 | # Usage:
12 | #   ./token.sh -k <YOUR_ANTHROPIC_API_KEY> -i <path/to/your/tools.json>
13 | #
14 | # Mandatory Arguments:
15 | #   -k, --api-key      : Your Anthropic API key.
16 | #   -i, --input-json   : Path to the JSON file containing the tool definitions.
17 | #                        This file should be an array of tool objects, each having
18 | #                        `name`, `description`, and `input_schema` fields.
19 | #
20 | # Example:
21 | #   # Assuming tools.json is in the current directory
22 | #   ./token.sh -k sk-ant-xxxxxxxx -i tools.json
23 | #
24 | # Output:
25 | #   The script outputs the JSON response from the Anthropic API,
26 | #   which typically includes the calculated token count.
27 | 
28 | # Default values
29 | API_KEY=""
30 | INPUT_JSON=""
31 | 
32 | # Parse command-line arguments
33 | TEMP=$(getopt -o k:i: --long api-key:,input-json: -n 'token.sh' -- "$@")
34 | if [ $? != 0 ] ; then echo "Terminating..." >&2 ; exit 1 ; fi
35 | 
36 | # Note the quotes around `$TEMP`: they are essential!
37 | eval set -- "$TEMP"
38 | 
39 | while true; do
40 |   case "$1" in
41 |     -k | --api-key ) API_KEY="$2"; shift 2 ;;
42 |     -i | --input-json ) INPUT_JSON="$2"; shift 2 ;;
43 |     -- ) shift; break ;;
44 |     * ) break ;;
45 |   esac
46 | done
47 | 
48 | # Validate mandatory arguments
49 | if [ -z "$API_KEY" ]; then
50 |     echo "Error: API Key is mandatory. Use -k or --api-key." >&2
51 |     exit 1
52 | fi
53 | 
54 | if [ -z "$INPUT_JSON" ]; then
55 |     echo "Error: Input JSON file path is mandatory. Use -i or --input-json." >&2
56 |     exit 1
57 | fi
58 | 
59 | if [ ! -f "$INPUT_JSON" ]; then
60 |     echo "Error: Input JSON file not found: $INPUT_JSON" >&2
61 |     exit 1
62 | fi
63 | 
64 | # Read tools definition from the input JSON file
65 | TOOLS_JSON=$(cat "$INPUT_JSON")
66 | 
67 | # Construct the JSON payload using jq
68 | # Note: We keep the example message structure for now.
69 | # We pass the tools JSON as a string argument to jq and use --argjson to parse it.
70 | JSON_PAYLOAD=$(jq -n --argjson tools "$TOOLS_JSON" '{
71 |   model: "claude-3-7-sonnet-20250219",
72 |   tools: $tools,
73 |   messages: [
74 |     {
75 |       role: "user",
76 |       content: "Show me a list of Portainer environments."
77 |     }
78 |   ]
79 | }')
80 | 
81 | # Check if jq succeeded
82 | if [ $? != 0 ]; then
83 |     echo "Error: Failed to construct JSON payload with jq. Is the input JSON valid?" >&2
84 |     exit 1
85 | fi
86 | 
87 | 
88 | # Make the API call
89 | curl https://api.anthropic.com/v1/messages/count_tokens \
90 |     --header "x-api-key: $API_KEY" \
91 |     --header "content-type: application/json" \
92 |     --header "anthropic-version: 2023-06-01" \
93 |     --data "$JSON_PAYLOAD"
94 | 
95 | echo # Add a newline for cleaner output
96 | 
```

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

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 
  6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  7 | )
  8 | 
  9 | // GetTeams retrieves all teams from the Portainer server.
 10 | //
 11 | // Returns:
 12 | //   - A slice of Team objects containing team information
 13 | //   - An error if the operation fails
 14 | func (c *PortainerClient) GetTeams() ([]models.Team, error) {
 15 | 	portainerTeams, err := c.cli.ListTeams()
 16 | 	if err != nil {
 17 | 		return nil, fmt.Errorf("failed to list teams: %w", err)
 18 | 	}
 19 | 
 20 | 	// Get team memberships to populate team members
 21 | 	memberships, err := c.cli.ListTeamMemberships()
 22 | 	if err != nil {
 23 | 		return nil, fmt.Errorf("failed to list team memberships: %w", err)
 24 | 	}
 25 | 
 26 | 	teams := make([]models.Team, len(portainerTeams))
 27 | 	for i, team := range portainerTeams {
 28 | 		teams[i] = models.ConvertToTeam(team, memberships)
 29 | 	}
 30 | 
 31 | 	return teams, nil
 32 | }
 33 | 
 34 | // UpdateTeamName updates the name of a team.
 35 | //
 36 | // Parameters:
 37 | //   - id: The ID of the team to update
 38 | //   - name: The new name for the team
 39 | func (c *PortainerClient) UpdateTeamName(id int, name string) error {
 40 | 	return c.cli.UpdateTeamName(id, name)
 41 | }
 42 | 
 43 | // CreateTeam creates a new team.
 44 | //
 45 | // Parameters:
 46 | //   - name: The name of the team
 47 | //
 48 | // Returns:
 49 | //   - The ID of the created team
 50 | //   - An error if the operation fails
 51 | func (c *PortainerClient) CreateTeam(name string) (int, error) {
 52 | 	id, err := c.cli.CreateTeam(name)
 53 | 	if err != nil {
 54 | 		return 0, fmt.Errorf("failed to create team: %w", err)
 55 | 	}
 56 | 
 57 | 	return int(id), nil
 58 | }
 59 | 
 60 | // UpdateTeamMembers updates the members of a team.
 61 | //
 62 | // Parameters:
 63 | //   - teamId: The ID of the team to update
 64 | //   - userIds: The IDs of the users associated with the team
 65 | func (c *PortainerClient) UpdateTeamMembers(teamId int, userIds []int) error {
 66 | 	memberships, err := c.cli.ListTeamMemberships()
 67 | 	if err != nil {
 68 | 		return fmt.Errorf("failed to list team memberships: %w", err)
 69 | 	}
 70 | 
 71 | 	// Track which users are already members of the team
 72 | 	existingMembers := make(map[int]bool)
 73 | 
 74 | 	// First, handle existing memberships
 75 | 	for _, membership := range memberships {
 76 | 		if membership.TeamID == int64(teamId) {
 77 | 			userID := membership.UserID
 78 | 			existingMembers[int(userID)] = true
 79 | 
 80 | 			// Check if this user should remain in the team
 81 | 			shouldKeep := false
 82 | 			for _, id := range userIds {
 83 | 				if id == int(userID) {
 84 | 					shouldKeep = true
 85 | 					break
 86 | 				}
 87 | 			}
 88 | 
 89 | 			// If user should not remain in the team, delete the membership
 90 | 			if !shouldKeep {
 91 | 				if err := c.cli.DeleteTeamMembership(int(membership.ID)); err != nil {
 92 | 					return fmt.Errorf("failed to delete team membership for user %d: %w", userID, err)
 93 | 				}
 94 | 			}
 95 | 		}
 96 | 	}
 97 | 
 98 | 	// Then, create memberships for new users
 99 | 	for _, userID := range userIds {
100 | 		// Skip if user is already a member
101 | 		if existingMembers[userID] {
102 | 			continue
103 | 		}
104 | 
105 | 		// Create new membership for this user
106 | 		if err := c.cli.CreateTeamMembership(teamId, userID); err != nil {
107 | 			return fmt.Errorf("failed to create team membership for user %d: %w", userID, err)
108 | 		}
109 | 	}
110 | 
111 | 	return nil
112 | }
113 | 
```

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

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/mark3labs/mcp-go/server"
 10 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 11 | )
 12 | 
 13 | func (s *PortainerMCPServer) AddTeamFeatures() {
 14 | 	s.addToolIfExists(ToolListTeams, s.HandleGetTeams())
 15 | 
 16 | 	if !s.readOnly {
 17 | 		s.addToolIfExists(ToolCreateTeam, s.HandleCreateTeam())
 18 | 		s.addToolIfExists(ToolUpdateTeamName, s.HandleUpdateTeamName())
 19 | 		s.addToolIfExists(ToolUpdateTeamMembers, s.HandleUpdateTeamMembers())
 20 | 	}
 21 | }
 22 | 
 23 | func (s *PortainerMCPServer) HandleCreateTeam() server.ToolHandlerFunc {
 24 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 25 | 		parser := toolgen.NewParameterParser(request)
 26 | 
 27 | 		name, err := parser.GetString("name", true)
 28 | 		if err != nil {
 29 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
 30 | 		}
 31 | 
 32 | 		teamID, err := s.cli.CreateTeam(name)
 33 | 		if err != nil {
 34 | 			return mcp.NewToolResultErrorFromErr("failed to create team", err), nil
 35 | 		}
 36 | 
 37 | 		return mcp.NewToolResultText(fmt.Sprintf("Team created successfully with ID: %d", teamID)), nil
 38 | 	}
 39 | }
 40 | 
 41 | func (s *PortainerMCPServer) HandleGetTeams() server.ToolHandlerFunc {
 42 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 43 | 		teams, err := s.cli.GetTeams()
 44 | 		if err != nil {
 45 | 			return mcp.NewToolResultErrorFromErr("failed to get teams", err), nil
 46 | 		}
 47 | 
 48 | 		data, err := json.Marshal(teams)
 49 | 		if err != nil {
 50 | 			return mcp.NewToolResultErrorFromErr("failed to marshal teams", err), nil
 51 | 		}
 52 | 
 53 | 		return mcp.NewToolResultText(string(data)), nil
 54 | 	}
 55 | }
 56 | 
 57 | func (s *PortainerMCPServer) HandleUpdateTeamName() server.ToolHandlerFunc {
 58 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 59 | 		parser := toolgen.NewParameterParser(request)
 60 | 
 61 | 		id, err := parser.GetInt("id", true)
 62 | 		if err != nil {
 63 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 64 | 		}
 65 | 
 66 | 		name, err := parser.GetString("name", true)
 67 | 		if err != nil {
 68 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
 69 | 		}
 70 | 
 71 | 		err = s.cli.UpdateTeamName(id, name)
 72 | 		if err != nil {
 73 | 			return mcp.NewToolResultErrorFromErr("failed to update team name", err), nil
 74 | 		}
 75 | 
 76 | 		return mcp.NewToolResultText("Team name updated successfully"), nil
 77 | 	}
 78 | }
 79 | 
 80 | func (s *PortainerMCPServer) HandleUpdateTeamMembers() server.ToolHandlerFunc {
 81 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 82 | 		parser := toolgen.NewParameterParser(request)
 83 | 
 84 | 		id, err := parser.GetInt("id", true)
 85 | 		if err != nil {
 86 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 87 | 		}
 88 | 
 89 | 		userIDs, err := parser.GetArrayOfIntegers("userIds", true)
 90 | 		if err != nil {
 91 | 			return mcp.NewToolResultErrorFromErr("invalid userIds parameter", err), nil
 92 | 		}
 93 | 
 94 | 		err = s.cli.UpdateTeamMembers(id, userIDs)
 95 | 		if err != nil {
 96 | 			return mcp.NewToolResultErrorFromErr("failed to update team members", err), nil
 97 | 		}
 98 | 
 99 | 		return mcp.NewToolResultText("Team members updated successfully"), nil
100 | 	}
101 | }
102 | 
```

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

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"errors"
  6 | 	"io"
  7 | 	"net/http"
  8 | 	"strings"
  9 | 	"testing"
 10 | 
 11 | 	"github.com/portainer/client-api-go/v2/client"
 12 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 13 | 	"github.com/stretchr/testify/assert"
 14 | )
 15 | 
 16 | func TestProxyDockerRequest(t *testing.T) {
 17 | 	tests := []struct {
 18 | 		name             string
 19 | 		environmentId    int
 20 | 		opts             models.DockerProxyRequestOptions
 21 | 		mockResponse     *http.Response
 22 | 		mockError        error
 23 | 		expectedError    bool
 24 | 		expectedStatus   int
 25 | 		expectedRespBody string
 26 | 	}{
 27 | 		{
 28 | 			name: "GET request with query parameters",
 29 | 			opts: models.DockerProxyRequestOptions{
 30 | 				EnvironmentID: 1,
 31 | 				Method:        "GET",
 32 | 				Path:          "/images/json",
 33 | 				QueryParams:   map[string]string{"all": "true", "filter": "dangling"},
 34 | 			},
 35 | 			mockResponse: &http.Response{
 36 | 				StatusCode: http.StatusOK,
 37 | 				Body:       io.NopCloser(strings.NewReader(`[{"Id":"img1"}]`)),
 38 | 			},
 39 | 			mockError:        nil,
 40 | 			expectedError:    false,
 41 | 			expectedStatus:   http.StatusOK,
 42 | 			expectedRespBody: `[{"Id":"img1"}]`,
 43 | 		},
 44 | 		{
 45 | 			name: "POST request with custom headers",
 46 | 			opts: models.DockerProxyRequestOptions{
 47 | 				EnvironmentID: 2,
 48 | 				Method:        "POST",
 49 | 				Path:          "/networks/create",
 50 | 				Headers:       map[string]string{"X-Custom-Header": "value1", "Authorization": "Bearer token"},
 51 | 				Body:          bytes.NewBufferString(`{"Name": "my-network"}`),
 52 | 			},
 53 | 			mockResponse: &http.Response{
 54 | 				StatusCode: http.StatusCreated,
 55 | 				Body:       io.NopCloser(strings.NewReader(`{"Id": "net1"}`)),
 56 | 			},
 57 | 			mockError:        nil,
 58 | 			expectedError:    false,
 59 | 			expectedStatus:   http.StatusCreated,
 60 | 			expectedRespBody: `{"Id": "net1"}`,
 61 | 		},
 62 | 		{
 63 | 			name: "API error",
 64 | 			opts: models.DockerProxyRequestOptions{
 65 | 				EnvironmentID: 3,
 66 | 				Method:        "GET",
 67 | 				Path:          "/version",
 68 | 			},
 69 | 			mockResponse:     nil,
 70 | 			mockError:        errors.New("failed to proxy request"),
 71 | 			expectedError:    true,
 72 | 			expectedStatus:   0,  // Not applicable
 73 | 			expectedRespBody: "", // Not applicable
 74 | 		},
 75 | 	}
 76 | 
 77 | 	for _, tt := range tests {
 78 | 		t.Run(tt.name, func(t *testing.T) {
 79 | 			mockAPI := new(MockPortainerAPI)
 80 | 			opts := client.ProxyRequestOptions{
 81 | 				Method:      tt.opts.Method,
 82 | 				APIPath:     tt.opts.Path,
 83 | 				QueryParams: tt.opts.QueryParams,
 84 | 				Headers:     tt.opts.Headers,
 85 | 				Body:        tt.opts.Body,
 86 | 			}
 87 | 			mockAPI.On("ProxyDockerRequest", tt.opts.EnvironmentID, opts).Return(tt.mockResponse, tt.mockError)
 88 | 
 89 | 			client := &PortainerClient{cli: mockAPI}
 90 | 
 91 | 			resp, err := client.ProxyDockerRequest(tt.opts)
 92 | 			if tt.expectedError {
 93 | 				assert.Error(t, err)
 94 | 				assert.EqualError(t, err, tt.mockError.Error())
 95 | 				assert.Nil(t, resp)
 96 | 			} else {
 97 | 				assert.NoError(t, err)
 98 | 				assert.NotNil(t, resp)
 99 | 				assert.Equal(t, tt.expectedStatus, resp.StatusCode)
100 | 
101 | 				// Read and verify the response body
102 | 				if assert.NotNil(t, resp.Body) { // Ensure body is not nil before reading
103 | 					defer resp.Body.Close()
104 | 					bodyBytes, readErr := io.ReadAll(resp.Body)
105 | 					assert.NoError(t, readErr)
106 | 					assert.Equal(t, tt.expectedRespBody, string(bodyBytes))
107 | 				}
108 | 			}
109 | 
110 | 			mockAPI.AssertExpectations(t)
111 | 		})
112 | 	}
113 | }
114 | 
```

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

```go
  1 | package utils
  2 | 
  3 | import (
  4 | 	"reflect"
  5 | 	"testing"
  6 | )
  7 | 
  8 | func TestInt64ToIntSlice(t *testing.T) {
  9 | 	tests := []struct {
 10 | 		name   string
 11 | 		int64s []int64
 12 | 		want   []int
 13 | 	}{
 14 | 		{
 15 | 			name:   "empty slice",
 16 | 			int64s: []int64{},
 17 | 			want:   []int{},
 18 | 		},
 19 | 		{
 20 | 			name:   "single element",
 21 | 			int64s: []int64{42},
 22 | 			want:   []int{42},
 23 | 		},
 24 | 		{
 25 | 			name:   "multiple elements",
 26 | 			int64s: []int64{1, 2, 3, 4, 5},
 27 | 			want:   []int{1, 2, 3, 4, 5},
 28 | 		},
 29 | 		{
 30 | 			name:   "large numbers",
 31 | 			int64s: []int64{1000000000, 2000000000},
 32 | 			want:   []int{1000000000, 2000000000},
 33 | 		},
 34 | 		{
 35 | 			name:   "negative numbers",
 36 | 			int64s: []int64{-1, -10, -100},
 37 | 			want:   []int{-1, -10, -100},
 38 | 		},
 39 | 	}
 40 | 
 41 | 	for _, tt := range tests {
 42 | 		t.Run(tt.name, func(t *testing.T) {
 43 | 			got := Int64ToIntSlice(tt.int64s)
 44 | 			if !reflect.DeepEqual(got, tt.want) {
 45 | 				t.Errorf("Int64ToIntSlice() = %v, want %v", got, tt.want)
 46 | 			}
 47 | 		})
 48 | 	}
 49 | }
 50 | 
 51 | func TestIntToInt64Slice(t *testing.T) {
 52 | 	tests := []struct {
 53 | 		name string
 54 | 		ints []int
 55 | 		want []int64
 56 | 	}{
 57 | 		{
 58 | 			name: "empty slice",
 59 | 			ints: []int{},
 60 | 			want: []int64{},
 61 | 		},
 62 | 		{
 63 | 			name: "single element",
 64 | 			ints: []int{42},
 65 | 			want: []int64{42},
 66 | 		},
 67 | 		{
 68 | 			name: "multiple elements",
 69 | 			ints: []int{1, 2, 3, 4, 5},
 70 | 			want: []int64{1, 2, 3, 4, 5},
 71 | 		},
 72 | 		{
 73 | 			name: "large numbers",
 74 | 			ints: []int{1000000000, 2000000000},
 75 | 			want: []int64{1000000000, 2000000000},
 76 | 		},
 77 | 		{
 78 | 			name: "negative numbers",
 79 | 			ints: []int{-1, -10, -100},
 80 | 			want: []int64{-1, -10, -100},
 81 | 		},
 82 | 		{
 83 | 			name: "max int32 value",
 84 | 			ints: []int{2147483647},
 85 | 			want: []int64{2147483647},
 86 | 		},
 87 | 	}
 88 | 
 89 | 	for _, tt := range tests {
 90 | 		t.Run(tt.name, func(t *testing.T) {
 91 | 			got := IntToInt64Slice(tt.ints)
 92 | 			if !reflect.DeepEqual(got, tt.want) {
 93 | 				t.Errorf("IntToInt64Slice() = %v, want %v", got, tt.want)
 94 | 			}
 95 | 		})
 96 | 	}
 97 | }
 98 | 
 99 | func TestIntToInt64Map(t *testing.T) {
100 | 	tests := []struct {
101 | 		name  string
102 | 		input map[int]string
103 | 		want  map[int64]string
104 | 	}{
105 | 		{
106 | 			name:  "empty map",
107 | 			input: map[int]string{},
108 | 			want:  map[int64]string{},
109 | 		},
110 | 		{
111 | 			name: "single key-value pair",
112 | 			input: map[int]string{
113 | 				1: "one",
114 | 			},
115 | 			want: map[int64]string{
116 | 				int64(1): "one",
117 | 			},
118 | 		},
119 | 		{
120 | 			name: "multiple key-value pairs",
121 | 			input: map[int]string{
122 | 				1: "one",
123 | 				2: "two",
124 | 				3: "three",
125 | 			},
126 | 			want: map[int64]string{
127 | 				int64(1): "one",
128 | 				int64(2): "two",
129 | 				int64(3): "three",
130 | 			},
131 | 		},
132 | 		{
133 | 			name: "negative keys",
134 | 			input: map[int]string{
135 | 				-1: "minus one",
136 | 				0:  "zero",
137 | 				1:  "one",
138 | 			},
139 | 			want: map[int64]string{
140 | 				int64(-1): "minus one",
141 | 				int64(0):  "zero",
142 | 				int64(1):  "one",
143 | 			},
144 | 		},
145 | 		{
146 | 			name: "large numbers",
147 | 			input: map[int]string{
148 | 				1000000: "million",
149 | 				9999999: "big number",
150 | 			},
151 | 			want: map[int64]string{
152 | 				int64(1000000): "million",
153 | 				int64(9999999): "big number",
154 | 			},
155 | 		},
156 | 		{
157 | 			name: "empty strings",
158 | 			input: map[int]string{
159 | 				1: "",
160 | 				2: "",
161 | 			},
162 | 			want: map[int64]string{
163 | 				int64(1): "",
164 | 				int64(2): "",
165 | 			},
166 | 		},
167 | 	}
168 | 
169 | 	for _, tt := range tests {
170 | 		t.Run(tt.name, func(t *testing.T) {
171 | 			got := IntToInt64Map(tt.input)
172 | 			if !reflect.DeepEqual(got, tt.want) {
173 | 				t.Errorf("IntToInt64Map() = %v, want %v", got, tt.want)
174 | 			}
175 | 		})
176 | 	}
177 | }
178 | 
```

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

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 
  6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  7 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
  8 | )
  9 | 
 10 | // GetEnvironmentGroups retrieves all environment groups from the Portainer server.
 11 | // Environment groups are the equivalent of Edge Groups in Portainer.
 12 | //
 13 | // Returns:
 14 | //   - A slice of Group objects
 15 | //   - An error if the operation fails
 16 | func (c *PortainerClient) GetEnvironmentGroups() ([]models.Group, error) {
 17 | 	edgeGroups, err := c.cli.ListEdgeGroups()
 18 | 	if err != nil {
 19 | 		return nil, fmt.Errorf("failed to list edge groups: %w", err)
 20 | 	}
 21 | 
 22 | 	groups := make([]models.Group, len(edgeGroups))
 23 | 	for i, eg := range edgeGroups {
 24 | 		groups[i] = models.ConvertEdgeGroupToGroup(eg)
 25 | 	}
 26 | 
 27 | 	return groups, nil
 28 | }
 29 | 
 30 | // CreateEnvironmentGroup creates a new environment group on the Portainer server.
 31 | // Environment groups are the equivalent of Edge Groups in Portainer.
 32 | // Parameters:
 33 | //   - name: The name of the environment group
 34 | //   - environmentIds: A slice of environment IDs to include in the group
 35 | //
 36 | // Returns:
 37 | //   - The ID of the created environment group
 38 | //   - An error if the operation fails
 39 | func (c *PortainerClient) CreateEnvironmentGroup(name string, environmentIds []int) (int, error) {
 40 | 	id, err := c.cli.CreateEdgeGroup(name, utils.IntToInt64Slice(environmentIds))
 41 | 	if err != nil {
 42 | 		return 0, fmt.Errorf("failed to create environment group: %w", err)
 43 | 	}
 44 | 
 45 | 	return int(id), nil
 46 | }
 47 | 
 48 | // UpdateEnvironmentGroupName updates the name of an existing environment group.
 49 | // Environment groups are the equivalent of Edge Groups in Portainer.
 50 | //
 51 | // Parameters:
 52 | //   - id: The ID of the environment group to update
 53 | //   - name: The new name for the environment group
 54 | //
 55 | // Returns:
 56 | //   - An error if the operation fails
 57 | func (c *PortainerClient) UpdateEnvironmentGroupName(id int, name string) error {
 58 | 	err := c.cli.UpdateEdgeGroup(int64(id), &name, nil, nil)
 59 | 	if err != nil {
 60 | 		return fmt.Errorf("failed to update environment group name: %w", err)
 61 | 	}
 62 | 	return nil
 63 | }
 64 | 
 65 | // UpdateEnvironmentGroupEnvironments updates the environments associated with an environment group.
 66 | // Environment groups are the equivalent of Edge Groups in Portainer.
 67 | //
 68 | // Parameters:
 69 | //   - id: The ID of the environment group to update
 70 | //   - environmentIds: A slice of environment IDs to include in the group
 71 | //
 72 | // Returns:
 73 | //   - An error if the operation fails
 74 | func (c *PortainerClient) UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error {
 75 | 	envs := utils.IntToInt64Slice(environmentIds)
 76 | 	err := c.cli.UpdateEdgeGroup(int64(id), nil, &envs, nil)
 77 | 	if err != nil {
 78 | 		return fmt.Errorf("failed to update environment group environments: %w", err)
 79 | 	}
 80 | 	return nil
 81 | }
 82 | 
 83 | // UpdateEnvironmentGroupTags updates the tags associated with an environment group.
 84 | // Environment groups are the equivalent of Edge Groups in Portainer.
 85 | //
 86 | // Parameters:
 87 | //   - id: The ID of the environment group to update
 88 | //   - tagIds: A slice of tag IDs to include in the group
 89 | //
 90 | // Returns:
 91 | //   - An error if the operation fails
 92 | func (c *PortainerClient) UpdateEnvironmentGroupTags(id int, tagIds []int) error {
 93 | 	tags := utils.IntToInt64Slice(tagIds)
 94 | 	err := c.cli.UpdateEdgeGroup(int64(id), nil, nil, &tags)
 95 | 	if err != nil {
 96 | 		return fmt.Errorf("failed to update environment group tags: %w", err)
 97 | 	}
 98 | 	return nil
 99 | }
100 | 
```

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

```go
  1 | package toolgen
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 
  6 | 	"github.com/mark3labs/mcp-go/mcp"
  7 | )
  8 | 
  9 | // ParameterParser provides methods to safely extract parameters from request arguments
 10 | type ParameterParser struct {
 11 | 	args map[string]any
 12 | }
 13 | 
 14 | // NewParameterParser creates a new parameter parser for the given request
 15 | func NewParameterParser(request mcp.CallToolRequest) *ParameterParser {
 16 | 	return &ParameterParser{
 17 | 		args: request.GetArguments(),
 18 | 	}
 19 | }
 20 | 
 21 | // GetString extracts a string parameter from the request
 22 | func (p *ParameterParser) GetString(name string, required bool) (string, error) {
 23 | 	value, ok := p.args[name]
 24 | 	if !ok || value == nil {
 25 | 		if required {
 26 | 			return "", fmt.Errorf("%s is required", name)
 27 | 		}
 28 | 		return "", nil
 29 | 	}
 30 | 
 31 | 	strValue, ok := value.(string)
 32 | 	if !ok {
 33 | 		return "", fmt.Errorf("%s must be a string", name)
 34 | 	}
 35 | 
 36 | 	return strValue, nil
 37 | }
 38 | 
 39 | // GetNumber extracts a number parameter from the request
 40 | func (p *ParameterParser) GetNumber(name string, required bool) (float64, error) {
 41 | 	value, ok := p.args[name]
 42 | 	if !ok || value == nil {
 43 | 		if required {
 44 | 			return 0, fmt.Errorf("%s is required", name)
 45 | 		}
 46 | 		return 0, nil
 47 | 	}
 48 | 
 49 | 	numValue, ok := value.(float64)
 50 | 	if !ok {
 51 | 		return 0, fmt.Errorf("%s must be a number", name)
 52 | 	}
 53 | 
 54 | 	return numValue, nil
 55 | }
 56 | 
 57 | // GetInt extracts an integer parameter from the request
 58 | func (p *ParameterParser) GetInt(name string, required bool) (int, error) {
 59 | 	num, err := p.GetNumber(name, required)
 60 | 	if err != nil {
 61 | 		return 0, err
 62 | 	}
 63 | 	return int(num), nil
 64 | }
 65 | 
 66 | // GetBoolean extracts a boolean parameter from the request
 67 | func (p *ParameterParser) GetBoolean(name string, required bool) (bool, error) {
 68 | 	value, ok := p.args[name]
 69 | 	if !ok || value == nil {
 70 | 		if required {
 71 | 			return false, fmt.Errorf("%s is required", name)
 72 | 		}
 73 | 		return false, nil
 74 | 	}
 75 | 
 76 | 	boolValue, ok := value.(bool)
 77 | 	if !ok {
 78 | 		return false, fmt.Errorf("%s must be a boolean", name)
 79 | 	}
 80 | 
 81 | 	return boolValue, nil
 82 | }
 83 | 
 84 | // GetArrayOfIntegers extracts an array of numbers parameter from the request
 85 | func (p *ParameterParser) GetArrayOfIntegers(name string, required bool) ([]int, error) {
 86 | 	value, ok := p.args[name]
 87 | 	if !ok || value == nil {
 88 | 		if required {
 89 | 			return nil, fmt.Errorf("%s is required", name)
 90 | 		}
 91 | 		return []int{}, nil
 92 | 	}
 93 | 
 94 | 	arrayValue, ok := value.([]any)
 95 | 	if !ok {
 96 | 		return nil, fmt.Errorf("%s must be an array", name)
 97 | 	}
 98 | 
 99 | 	return parseArrayOfIntegers(arrayValue)
100 | }
101 | 
102 | // GetArrayOfObjects extracts an array of objects parameter from the request
103 | func (p *ParameterParser) GetArrayOfObjects(name string, required bool) ([]any, error) {
104 | 	value, ok := p.args[name]
105 | 	if !ok || value == nil {
106 | 		if required {
107 | 			return nil, fmt.Errorf("%s is required", name)
108 | 		}
109 | 		return []any{}, nil
110 | 	}
111 | 
112 | 	arrayValue, ok := value.([]any)
113 | 	if !ok {
114 | 		return nil, fmt.Errorf("%s must be an array", name)
115 | 	}
116 | 
117 | 	return arrayValue, nil
118 | }
119 | 
120 | // parseArrayOfIntegers converts a slice of any type to a slice of integers.
121 | // Returns an error if any value cannot be parsed as an integer.
122 | //
123 | // Example:
124 | //
125 | //	ids, err := parseArrayOfIntegers([]any{1, 2, 3})
126 | //	// ids = []int{1, 2, 3}
127 | func parseArrayOfIntegers(array []any) ([]int, error) {
128 | 	result := make([]int, 0, len(array))
129 | 
130 | 	for _, item := range array {
131 | 		idFloat, ok := item.(float64)
132 | 		if !ok {
133 | 			return nil, fmt.Errorf("failed to parse '%v' as integer", item)
134 | 		}
135 | 		result = append(result, int(idFloat))
136 | 	}
137 | 
138 | 	return result, nil
139 | }
140 | 
```

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

```go
  1 | package k8sutil
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"fmt"
  6 | 	"io"
  7 | 	"net/http"
  8 | 
  9 | 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 10 | )
 11 | 
 12 | // removeManagedFieldsFromUnstructuredObject is a helper function that modifies an Unstructured object in place
 13 | // by removing the managedFields attribute from its metadata.
 14 | func removeManagedFieldsFromUnstructuredObject(obj *unstructured.Unstructured) error {
 15 | 	if obj == nil || obj.Object == nil {
 16 | 		return nil // Nothing to do
 17 | 	}
 18 | 
 19 | 	metadata, found, err := unstructured.NestedFieldCopy(obj.Object, "metadata")
 20 | 	if err != nil {
 21 | 		return fmt.Errorf("error fetching metadata for object %s (%s): %w", obj.GetName(), obj.GetKind(), err)
 22 | 	}
 23 | 	if !found {
 24 | 		return nil // Metadata not found, nothing to do
 25 | 	}
 26 | 
 27 | 	metadataMap, ok := metadata.(map[string]any)
 28 | 	if !ok {
 29 | 		return fmt.Errorf("metadata for object %s (%s) is not in the expected map format", obj.GetName(), obj.GetKind())
 30 | 	}
 31 | 
 32 | 	// Delete the managedFields key from the metadata map
 33 | 	delete(metadataMap, "managedFields")
 34 | 
 35 | 	// TODO: Consider also removing other verbose fields here, e.g., ownerReferences, if needed.
 36 | 	// delete(metadataMap, "ownerReferences")
 37 | 
 38 | 	// Set the modified metadata back to the object
 39 | 	err = unstructured.SetNestedField(obj.Object, metadataMap, "metadata")
 40 | 	if err != nil {
 41 | 		return fmt.Errorf("error setting modified metadata for object %s (%s): %w", obj.GetName(), obj.GetKind(), err)
 42 | 	}
 43 | 	return nil
 44 | }
 45 | 
 46 | // ProcessRawKubernetesAPIResponse takes an HTTP response, processes the JSON body,
 47 | // removes managedFields (and potentially other verbose metadata) from any Kubernetes resource(s) found,
 48 | // and returns the modified JSON bytes.
 49 | func ProcessRawKubernetesAPIResponse(httpResp *http.Response) ([]byte, error) {
 50 | 	if httpResp == nil {
 51 | 		return nil, fmt.Errorf("http response is nil")
 52 | 	}
 53 | 	if httpResp.Body == nil {
 54 | 		if httpResp.StatusCode != http.StatusNoContent && httpResp.ContentLength != 0 {
 55 | 			return nil, fmt.Errorf("http response body is nil but content was expected (status: %s)", httpResp.Status)
 56 | 		}
 57 | 		return []byte{}, nil // Return empty bytes if no body and appropriate status
 58 | 	}
 59 | 	defer httpResp.Body.Close()
 60 | 
 61 | 	bodyBytes, err := io.ReadAll(httpResp.Body)
 62 | 	if err != nil {
 63 | 		return nil, fmt.Errorf("failed to read response body: %w", err)
 64 | 	}
 65 | 
 66 | 	if len(bodyBytes) == 0 {
 67 | 		return bodyBytes, nil // Valid empty body
 68 | 	}
 69 | 
 70 | 	uObj := &unstructured.Unstructured{}
 71 | 	if err := uObj.UnmarshalJSON(bodyBytes); err != nil {
 72 | 		trimmedBody := string(bodyBytes)
 73 | 		if trimmedBody == "{}" || trimmedBody == "[]" {
 74 | 			return bodyBytes, nil // Valid empty JSON object/array
 75 | 		}
 76 | 		return nil, fmt.Errorf("failed to unmarshal JSON into Unstructured: %w. Body: %s", err, string(bodyBytes))
 77 | 	}
 78 | 
 79 | 	if uObj.IsList() {
 80 | 		list, err := uObj.ToList()
 81 | 		if err != nil {
 82 | 			return nil, fmt.Errorf("failed to convert Unstructured to UnstructuredList: %w", err)
 83 | 		}
 84 | 
 85 | 		for i := range list.Items {
 86 | 			if err := removeManagedFieldsFromUnstructuredObject(&list.Items[i]); err != nil {
 87 | 				return nil, fmt.Errorf("failed to remove managedFields from item %d in list: %w", i, err)
 88 | 			}
 89 | 		}
 90 | 		return json.Marshal(list)
 91 | 	} else {
 92 | 		if len(uObj.Object) == 0 {
 93 | 			return bodyBytes, nil // Empty object, nothing to process
 94 | 		}
 95 | 		if err := removeManagedFieldsFromUnstructuredObject(uObj); err != nil {
 96 | 			return nil, fmt.Errorf("failed to remove managedFields from single object: %w", err)
 97 | 		}
 98 | 		return json.Marshal(uObj)
 99 | 	}
100 | }
101 | 
```

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

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  9 | 	"github.com/stretchr/testify/assert"
 10 | )
 11 | 
 12 | func TestGetUsers(t *testing.T) {
 13 | 	tests := []struct {
 14 | 		name          string
 15 | 		mockUsers     []*apimodels.PortainereeUser
 16 | 		mockError     error
 17 | 		expected      []models.User
 18 | 		expectedError bool
 19 | 	}{
 20 | 		{
 21 | 			name: "successful retrieval - all role types",
 22 | 			mockUsers: []*apimodels.PortainereeUser{
 23 | 				{
 24 | 					ID:       1,
 25 | 					Username: "admin_user",
 26 | 					Role:     1, // admin
 27 | 				},
 28 | 				{
 29 | 					ID:       2,
 30 | 					Username: "regular_user",
 31 | 					Role:     2, // user
 32 | 				},
 33 | 				{
 34 | 					ID:       3,
 35 | 					Username: "edge_admin_user",
 36 | 					Role:     3, // edge_admin
 37 | 				},
 38 | 				{
 39 | 					ID:       4,
 40 | 					Username: "unknown_role_user",
 41 | 					Role:     0, // unknown
 42 | 				},
 43 | 			},
 44 | 			expected: []models.User{
 45 | 				{
 46 | 					ID:       1,
 47 | 					Username: "admin_user",
 48 | 					Role:     models.UserRoleAdmin,
 49 | 				},
 50 | 				{
 51 | 					ID:       2,
 52 | 					Username: "regular_user",
 53 | 					Role:     models.UserRoleUser,
 54 | 				},
 55 | 				{
 56 | 					ID:       3,
 57 | 					Username: "edge_admin_user",
 58 | 					Role:     models.UserRoleEdgeAdmin,
 59 | 				},
 60 | 				{
 61 | 					ID:       4,
 62 | 					Username: "unknown_role_user",
 63 | 					Role:     models.UserRoleUnknown,
 64 | 				},
 65 | 			},
 66 | 		},
 67 | 		{
 68 | 			name:      "empty users",
 69 | 			mockUsers: []*apimodels.PortainereeUser{},
 70 | 			expected:  []models.User{},
 71 | 		},
 72 | 		{
 73 | 			name:          "list error",
 74 | 			mockError:     errors.New("failed to list users"),
 75 | 			expectedError: true,
 76 | 		},
 77 | 	}
 78 | 
 79 | 	for _, tt := range tests {
 80 | 		t.Run(tt.name, func(t *testing.T) {
 81 | 			mockAPI := new(MockPortainerAPI)
 82 | 			mockAPI.On("ListUsers").Return(tt.mockUsers, tt.mockError)
 83 | 
 84 | 			client := &PortainerClient{cli: mockAPI}
 85 | 
 86 | 			users, err := client.GetUsers()
 87 | 
 88 | 			if tt.expectedError {
 89 | 				assert.Error(t, err)
 90 | 				return
 91 | 			}
 92 | 			assert.NoError(t, err)
 93 | 			assert.Equal(t, tt.expected, users)
 94 | 			mockAPI.AssertExpectations(t)
 95 | 		})
 96 | 	}
 97 | }
 98 | 
 99 | func TestUpdateUserRole(t *testing.T) {
100 | 	tests := []struct {
101 | 		name          string
102 | 		userID        int
103 | 		role          string
104 | 		expectedRole  int64
105 | 		mockError     error
106 | 		expectedError bool
107 | 	}{
108 | 		{
109 | 			name:         "update to admin role",
110 | 			userID:       1,
111 | 			role:         models.UserRoleAdmin,
112 | 			expectedRole: 1,
113 | 		},
114 | 		{
115 | 			name:         "update to regular user role",
116 | 			userID:       2,
117 | 			role:         models.UserRoleUser,
118 | 			expectedRole: 2,
119 | 		},
120 | 		{
121 | 			name:         "update to edge admin role",
122 | 			userID:       3,
123 | 			role:         models.UserRoleEdgeAdmin,
124 | 			expectedRole: 3,
125 | 		},
126 | 		{
127 | 			name:          "invalid role",
128 | 			userID:        4,
129 | 			role:          "invalid_role",
130 | 			expectedError: true,
131 | 		},
132 | 		{
133 | 			name:          "update error",
134 | 			userID:        5,
135 | 			role:          models.UserRoleAdmin,
136 | 			expectedRole:  1,
137 | 			mockError:     errors.New("failed to update user role"),
138 | 			expectedError: true,
139 | 		},
140 | 	}
141 | 
142 | 	for _, tt := range tests {
143 | 		t.Run(tt.name, func(t *testing.T) {
144 | 			mockAPI := new(MockPortainerAPI)
145 | 			if !tt.expectedError || tt.mockError != nil {
146 | 				mockAPI.On("UpdateUserRole", tt.userID, tt.expectedRole).Return(tt.mockError)
147 | 			}
148 | 
149 | 			client := &PortainerClient{cli: mockAPI}
150 | 
151 | 			err := client.UpdateUserRole(tt.userID, tt.role)
152 | 
153 | 			if tt.expectedError {
154 | 				assert.Error(t, err)
155 | 				return
156 | 			}
157 | 			assert.NoError(t, err)
158 | 			mockAPI.AssertExpectations(t)
159 | 		})
160 | 	}
161 | }
162 | 
```
Page 1/5FirstPrevNextLast