mcp-portainer 2.41.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_portainer-2.41.0/.claude/skills/portainer-mcp-hygiene/SKILL.md +149 -0
- mcp_portainer-2.41.0/.env.example +10 -0
- mcp_portainer-2.41.0/.github/workflows/ci.yml +17 -0
- mcp_portainer-2.41.0/.github/workflows/release-test.yml +37 -0
- mcp_portainer-2.41.0/.github/workflows/release.yml +44 -0
- mcp_portainer-2.41.0/.gitignore +8 -0
- mcp_portainer-2.41.0/CHANGELOG.md +89 -0
- mcp_portainer-2.41.0/CLAUDE.md +115 -0
- mcp_portainer-2.41.0/LICENSE +9 -0
- mcp_portainer-2.41.0/Makefile +29 -0
- mcp_portainer-2.41.0/PKG-INFO +11 -0
- mcp_portainer-2.41.0/README.md +78 -0
- mcp_portainer-2.41.0/docs/architecture.md +87 -0
- mcp_portainer-2.41.0/docs/distribution/claude-desktop.md +22 -0
- mcp_portainer-2.41.0/docs/profiles.md +98 -0
- mcp_portainer-2.41.0/docs/release.md +69 -0
- mcp_portainer-2.41.0/docs/versioning.md +27 -0
- mcp_portainer-2.41.0/pyproject.toml +34 -0
- mcp_portainer-2.41.0/spec/patch_spec.py +89 -0
- mcp_portainer-2.41.0/src/portainer_mcp/__init__.py +0 -0
- mcp_portainer-2.41.0/src/portainer_mcp/data/portainer-patched.yaml +29616 -0
- mcp_portainer-2.41.0/src/portainer_mcp/profiles.py +88 -0
- mcp_portainer-2.41.0/src/portainer_mcp/proxy.py +182 -0
- mcp_portainer-2.41.0/src/portainer_mcp/server.py +159 -0
- mcp_portainer-2.41.0/src/portainer_mcp/shaping.py +168 -0
- mcp_portainer-2.41.0/tests/conftest.py +15 -0
- mcp_portainer-2.41.0/tests/test_patch_spec.py +121 -0
- mcp_portainer-2.41.0/tests/test_profiles.py +83 -0
- mcp_portainer-2.41.0/tests/test_proxy.py +80 -0
- mcp_portainer-2.41.0/tests/test_shaping.py +62 -0
- mcp_portainer-2.41.0/uv.lock +1480 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: portainer-mcp-hygiene
|
|
3
|
+
description: How to efficiently query the Portainer MCP server's tools — when to project responses with `select` (JMESPath), where the heavy fields live (snapshots, status blocks, managed fields), and how to handle non-JSON Docker/K8s proxy endpoints (logs, stats, exec). Trigger this whenever you're about to call any Portainer MCP tool — including `docker_proxy`, `kubernetes_proxy`, `EndpointList`, `GetAllKubernetes*`, `StackList`, `snapshot*`, `Helm*`, or any other `mcp__portainer__*` tool — and whenever the user asks about Portainer environments, Docker containers/images/stacks/networks managed by Portainer, Kubernetes resources via Portainer, or Helm releases. Use it even if the user doesn't mention Portainer by name, as long as the working answer requires one of these tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Portainer MCP hygiene
|
|
7
|
+
|
|
8
|
+
The Portainer MCP server returns large JSON payloads by default — a list of environments with snapshots, a list of K8s pods with full status blocks, a stack with its complete manifest. Every tool the server exposes accepts an optional `select` (JMESPath) parameter applied server-side before the response reaches you. Responses are capped at ~50,000 chars; if you exceed the cap you get a truncation hint that names `select` and shows an example.
|
|
9
|
+
|
|
10
|
+
The cost of *not* projecting is real: 50K chars of dense JSON eats roughly 20K tokens out of your context for a question that usually needed a few hundred. Once truncation fires, you've wasted a round trip and the data past the cap is gone for that call. The default move on any list-shaped Portainer call is to pass `select` from the start.
|
|
11
|
+
|
|
12
|
+
## The default pattern
|
|
13
|
+
|
|
14
|
+
For any call that returns a list of objects, ship a JMESPath that keeps only the fields the user's question actually needs:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
EndpointList(select="[].{id:Id,name:Name,type:Type,status:Status}")
|
|
18
|
+
docker_proxy(path="/containers/json", select="[].{id:Id,name:Names[0],state:State,image:Image}")
|
|
19
|
+
kubernetes_proxy(path="/api/v1/pods", select="items[].{name:metadata.name,ns:metadata.namespace,phase:status.phase,node:spec.nodeName}")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
JMESPath syntax notes that matter for these surfaces:
|
|
23
|
+
- List shape: start with `[]` to map over array elements.
|
|
24
|
+
- Wrapped list (Kubernetes `{items: [...]}`): start with `items[]`.
|
|
25
|
+
- Single object: `{field1:path.to.value,field2:other.path}` — no leading `[]`.
|
|
26
|
+
- Nested paths use dots: `Snapshots[0].RunningContainerCount`, `metadata.labels."app.kubernetes.io/name"` (quote keys that contain dots or hyphens).
|
|
27
|
+
|
|
28
|
+
## Where the noise lives
|
|
29
|
+
|
|
30
|
+
These are the fields/sections that dominate Portainer payloads. Either project them out (when you don't need them) or project specifically into them (when they *are* the answer):
|
|
31
|
+
|
|
32
|
+
**`EndpointList` — `.Snapshots[0]` carries the heavy payload.**
|
|
33
|
+
Each environment includes a full Docker or Kubernetes snapshot — container list, image list, network list, etc. For counts and status questions you almost always want to project into specific snapshot fields rather than fetch them whole:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
# Container counts per environment
|
|
37
|
+
EndpointList(select="[].{name:Name,running:Snapshots[0].RunningContainerCount,total:Snapshots[0].ContainerCount}")
|
|
38
|
+
|
|
39
|
+
# Just identity + reachability
|
|
40
|
+
EndpointList(select="[].{id:Id,name:Name,type:Type,status:Status}")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Kubernetes via `kubernetes_proxy` — `metadata.managedFields` and `status` are huge.**
|
|
44
|
+
`metadata.managedFields` alone is routinely 30-70% of an object. The `status` block on Deployments, StatefulSets, Pods, and Nodes is similarly verbose. Project them out unless the user is asking about reconciliation state or controller history:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
# Pod summary
|
|
48
|
+
kubernetes_proxy(path="/api/v1/pods", select="items[].{name:metadata.name,ns:metadata.namespace,phase:status.phase,restarts:status.containerStatuses[0].restartCount,node:spec.nodeName}")
|
|
49
|
+
|
|
50
|
+
# Deployment readiness
|
|
51
|
+
kubernetes_proxy(path="/apis/apps/v1/deployments", select="items[].{name:metadata.name,ns:metadata.namespace,replicas:spec.replicas,ready:status.readyReplicas}")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**`GetAllKubernetes*` tools — full status blocks per object.**
|
|
55
|
+
The OpenAPI-generated `GetAllKubernetesApplications`, `GetAllKubernetesConfigMaps`, `GetAllKubernetesIngresses`, etc. return arrays where each element carries its full object body. Same rules as the proxy: project to the named fields you need.
|
|
56
|
+
|
|
57
|
+
**`StackList` and `StackInspect` — config and env vars.**
|
|
58
|
+
Stacks carry the full compose/manifest content plus environment variable dictionaries. If the user asked "which stacks exist?", project to `{id, name, type, status}`. If they asked about a specific stack's config, fetch it directly and only then look at the body.
|
|
59
|
+
|
|
60
|
+
**Snapshot inspects (`snapshotInspect`, `snapshotContainersList`, etc.) — entire snapshots.**
|
|
61
|
+
These return the *whole* snapshot blob by design. Always project.
|
|
62
|
+
|
|
63
|
+
**Helm endpoints — full chart values and manifests.**
|
|
64
|
+
`HelmList` carries release status + chart metadata; `HelmGet` returns the rendered manifest. Project to release names and status when listing; only fetch the manifest when the user asked to see it.
|
|
65
|
+
|
|
66
|
+
**`EndpointGetCharts`, `dockerDashboard`, `EndpointSummaryCounts` — already aggregated.**
|
|
67
|
+
These are the lightweight "summary" tools. Prefer them over `EndpointList` + projection when the user's question is purely a count or rollup — fewer characters, less work, more accurate (server-side aggregation).
|
|
68
|
+
|
|
69
|
+
## Patterns for common questions
|
|
70
|
+
|
|
71
|
+
A few high-frequency questions and the projection that gets them in one call:
|
|
72
|
+
|
|
73
|
+
**"How many running containers in each environment?"**
|
|
74
|
+
```
|
|
75
|
+
EndpointList(select="[].{name:Name,type:Type,running:Snapshots[0].RunningContainerCount,total:Snapshots[0].ContainerCount}")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**"List containers in environment N."**
|
|
79
|
+
```
|
|
80
|
+
docker_proxy(environment_id=N, path="/containers/json",
|
|
81
|
+
select="[].{id:Id,name:Names[0],state:State,image:Image,status:Status}")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**"Which images are in use, grouped by name?"**
|
|
85
|
+
Fetch with projection, group client-side:
|
|
86
|
+
```
|
|
87
|
+
docker_proxy(environment_id=N, path="/containers/json", select="[].Image")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**"One-line pod summary in environment N."**
|
|
91
|
+
```
|
|
92
|
+
kubernetes_proxy(environment_id=N, path="/api/v1/pods",
|
|
93
|
+
select="items[].{name:metadata.name,ns:metadata.namespace,phase:status.phase,node:spec.nodeName}")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**"Which deployments aren't fully ready?"**
|
|
97
|
+
Project readiness fields, then filter in the response. (JMESPath can also filter inline with `items[?status.readyReplicas != spec.replicas]`, but expressions like that are easy to get wrong — projection + your own filter is usually safer.)
|
|
98
|
+
|
|
99
|
+
**"Inspect deployment X in namespace Y."**
|
|
100
|
+
A single-object fetch. Project out `metadata.managedFields` and `status.conditions` if you only need the spec; keep them if the user is asking about reconciliation:
|
|
101
|
+
```
|
|
102
|
+
kubernetes_proxy(environment_id=N, path="/apis/apps/v1/namespaces/Y/deployments/X",
|
|
103
|
+
select="{name:metadata.name,replicas:spec.replicas,ready:status.readyReplicas,image:spec.template.spec.containers[0].image}")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Non-JSON endpoints — `select` does not apply
|
|
107
|
+
|
|
108
|
+
A handful of `docker_proxy` and `kubernetes_proxy` paths return plain text or streamed data rather than JSON. `select` is a no-op on these (the proxy detects non-JSON and passes the body through unchanged), but the response-size cap still fires, and `_select_wrapper` will raise a JSON parse error if you do pass `select`. **Narrow the upstream query parameters instead.**
|
|
109
|
+
|
|
110
|
+
**Container logs** — `/containers/{id}/logs`:
|
|
111
|
+
- Set `tail` to limit lines (`tail=100` for the last hundred).
|
|
112
|
+
- Set `since` to limit time range (Unix timestamp).
|
|
113
|
+
- Always pass `stdout=true` and/or `stderr=true` — without them the daemon returns nothing.
|
|
114
|
+
- Don't set `follow=true` — it streams indefinitely and will burn your context.
|
|
115
|
+
|
|
116
|
+
**Container stats** — `/containers/{id}/stats`:
|
|
117
|
+
- Always pass `stream=false` to get a single snapshot. The streaming form is unbounded.
|
|
118
|
+
|
|
119
|
+
**Container exec output** — chunked stream.
|
|
120
|
+
- If you need command output, prefer `docker_proxy` against `/containers/{id}/top` for process listing, or run the command another way. Exec attach over HTTP returns multiplexed binary frames and won't render usefully through the cap.
|
|
121
|
+
|
|
122
|
+
**Image pulls / archives / build context** — binary or streamed.
|
|
123
|
+
- Don't fetch these through the proxy for inspection. Use the specific Portainer endpoints (`endpointDockerhubStatus`, `ServiceImageStatus`, `dockerImagesList`) which return parseable JSON summaries.
|
|
124
|
+
|
|
125
|
+
If the cap fires on a non-JSON endpoint, the truncation hint will suggest `select` — ignore that suggestion in this case and retry with narrower upstream parameters.
|
|
126
|
+
|
|
127
|
+
## When *not* to project
|
|
128
|
+
|
|
129
|
+
Projecting isn't always right:
|
|
130
|
+
|
|
131
|
+
- **Small single-object reads** that you already know are under a few KB — `SettingsInspect`, `MOTD`, `StatusInspect`, `systemVersion`. Projecting just adds a round of cognitive overhead for no win.
|
|
132
|
+
|
|
133
|
+
- **Exploratory scans where you don't know what you're looking for** — "anything unusual in this stack's config", "is there an error somewhere in this deployment's status". Here you want the full body so you can scan for patterns. Pull the full object; if it truncates, narrow the *path* (one resource, not the whole list) rather than projecting fields.
|
|
134
|
+
|
|
135
|
+
- **When the user asked for "everything"** — sometimes they really do want the raw object. Respect that, but warn them once if you're about to retrieve something that will eat their context.
|
|
136
|
+
|
|
137
|
+
## Reading the truncation hint
|
|
138
|
+
|
|
139
|
+
When you do hit the cap, the response ends with a bracketed `[truncated: ... Retry with a JMESPath `select` ...]` message that includes a concrete example. Your next move should almost always be: retry the *same* call with a `select` projection — not pivot to reading the spilled file with `jq`, not paginate by guessing offsets, not call a different tool. The server-side projection is cheaper (no re-fetch from Portainer if the data was already cached upstream, and far fewer tokens shipped back).
|
|
140
|
+
|
|
141
|
+
The exception is non-JSON endpoints (see above) — there, ignore the `select` suggestion and re-shape the upstream query instead.
|
|
142
|
+
|
|
143
|
+
## Tool selection cheatsheet
|
|
144
|
+
|
|
145
|
+
- Environment-level summary (counts, status, reachability) → `EndpointList` with snapshot projection, or `EndpointSummaryCounts`/`dockerDashboard` if the question is purely aggregate.
|
|
146
|
+
- Docker things on a specific environment → `docker_proxy`. The OpenAPI-generated `dockerContainerGpusInspect`, `containerImageStatus`, etc. are specific helpers; use them when they directly answer the question, otherwise the proxy is more flexible.
|
|
147
|
+
- Kubernetes things on a specific environment → either the OpenAPI-generated `GetAllKubernetes*` / `GetKubernetes*` tools (Portainer-aware, often already filtered) or `kubernetes_proxy` (raw K8s API, full flexibility). Prefer the typed tool when it exists; fall back to the proxy for paths Portainer doesn't surface natively.
|
|
148
|
+
- Helm releases → `HelmList`, `HelmGet`, `HelmGetHistory`. Don't try to route Helm through the K8s proxy — Portainer's Helm tools see the release metadata the K8s API alone doesn't.
|
|
149
|
+
- Mutations (POST/PUT/DELETE) → only in read-write mode. If the server is in `PORTAINER_READ_ONLY=1`, non-GET calls are rejected at the tool with a clear error. Don't retry mutations as GET when this happens — surface the read-only state to the user.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copy to .env and fill in. Used by `make dev`.
|
|
2
|
+
|
|
3
|
+
# Required
|
|
4
|
+
PORTAINER_URL=https://portainer.example.com
|
|
5
|
+
PORTAINER_API_KEY=ptr_xxxxxxxxxxxxxxxx
|
|
6
|
+
|
|
7
|
+
# Optional — see README for the full list
|
|
8
|
+
# PORTAINER_TLS_VERIFY=0
|
|
9
|
+
# PORTAINER_PROFILES=ALL
|
|
10
|
+
# PORTAINER_MCP_LOG_LEVEL=DEBUG
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
13
|
+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
|
14
|
+
with:
|
|
15
|
+
enable-cache: true
|
|
16
|
+
- run: uv sync --frozen
|
|
17
|
+
- run: uv run pytest
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Dry-run release to TestPyPI. Triggered manually from the Actions tab.
|
|
2
|
+
# See docs/release.md for setup and process.
|
|
3
|
+
|
|
4
|
+
name: Release (TestPyPI)
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
14
|
+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
|
15
|
+
with:
|
|
16
|
+
enable-cache: true
|
|
17
|
+
- run: uv sync --frozen
|
|
18
|
+
- run: uv run pytest
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
permissions:
|
|
29
|
+
id-token: write
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
32
|
+
with:
|
|
33
|
+
name: dist
|
|
34
|
+
path: dist/
|
|
35
|
+
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
|
36
|
+
with:
|
|
37
|
+
repository-url: https://test.pypi.org/legacy/
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# See docs/release.md for the release process and one-time setup.
|
|
2
|
+
|
|
3
|
+
name: Release
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags:
|
|
8
|
+
- '[0-9]+.[0-9]+.[0-9]+'
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
15
|
+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
|
16
|
+
with:
|
|
17
|
+
enable-cache: true
|
|
18
|
+
- run: uv sync --frozen
|
|
19
|
+
- name: Verify tag matches pyproject version
|
|
20
|
+
run: |
|
|
21
|
+
tag="${GITHUB_REF#refs/tags/}"
|
|
22
|
+
version=$(uv run python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
23
|
+
if [ "$tag" != "$version" ]; then
|
|
24
|
+
echo "tag $tag does not match pyproject version $version" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
- run: uv run pytest
|
|
28
|
+
- run: uv build
|
|
29
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
|
|
34
|
+
publish:
|
|
35
|
+
needs: build
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
permissions:
|
|
38
|
+
id-token: write
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
The versioning policy is described in [`docs/versioning.md`](docs/versioning.md)
|
|
7
|
+
— major+minor tracks the Portainer API version; the patch slot belongs to
|
|
8
|
+
the MCP server.
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [2.41.0] — 2026-05-19
|
|
13
|
+
|
|
14
|
+
Initial release. Targets Portainer 2.41.x. Distributed
|
|
15
|
+
on PyPI as `mcp-portainer`.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Tool surface from the Portainer OpenAPI spec** via
|
|
20
|
+
`FastMCP.from_openapi`. The patched spec (EE 2.41.1) ships inside the
|
|
21
|
+
wheel at `src/portainer_mcp/data/portainer-patched.yaml`, loaded via
|
|
22
|
+
`importlib.resources`. End users do not run the patcher.
|
|
23
|
+
- **Profile-based tag allowlist** at
|
|
24
|
+
[`src/portainer_mcp/profiles.py`](src/portainer_mcp/profiles.py): five
|
|
25
|
+
named bundles (`BASE`, `DOCKER`, `KUBERNETES`, `EDGE`, `ADMIN`) plus an
|
|
26
|
+
`ALL` sentinel, selected via `PORTAINER_PROFILES`. Unknown profile
|
|
27
|
+
names fail loudly at startup; `PORTAINER_TAGS_EXTRA` is the escape
|
|
28
|
+
hatch for orphan tags. Default `BASE,DOCKER,KUBERNETES` covers ~180 of
|
|
29
|
+
387 spec operations. See [`docs/profiles.md`](docs/profiles.md).
|
|
30
|
+
- **Orthogonal modifiers**: `PORTAINER_READ_ONLY=1` filters to `GET` /
|
|
31
|
+
`HEAD` only; `PORTAINER_NO_PROXY=1` skips proxy-tool registration.
|
|
32
|
+
- **Universal response shaping**: every tool — generated OpenAPI tools
|
|
33
|
+
and hand-written proxies alike — accepts an optional JMESPath `select`
|
|
34
|
+
argument applied server-side. Implemented via
|
|
35
|
+
`shaping.SelectArgTransform` (a `fastmcp.server.transforms.Transform`
|
|
36
|
+
subclass) registered with `mcp.add_transform(...)`. Startup canary
|
|
37
|
+
(`await mcp.list_tools()`) raises if any tool is missing `select`.
|
|
38
|
+
- **Response truncation hint**: `ResponseCapMiddleware` caps responses
|
|
39
|
+
at `PORTAINER_MAX_RESPONSE_CHARS` (default 50000, ~80% of Claude
|
|
40
|
+
Code's MCP ceiling) and appends a `select`-teaching hint with a
|
|
41
|
+
concrete example before the client's own cap fires.
|
|
42
|
+
- **Hand-written proxy tools** for endpoints the OpenAPI spec can't
|
|
43
|
+
express cleanly: `docker_proxy` and `kubernetes_proxy`, with
|
|
44
|
+
validators rejecting `..` / `?` / `#` in paths and a blocked-header
|
|
45
|
+
list. JMESPath projection passes through non-JSON responses unchanged
|
|
46
|
+
(logs, stats, exec).
|
|
47
|
+
- **HTTP transport mode** via `PORTAINER_MCP_TRANSPORT=http` plus
|
|
48
|
+
`PORTAINER_MCP_HTTP_HOST` (default `127.0.0.1`) and
|
|
49
|
+
`PORTAINER_MCP_HTTP_PORT` (default `8000`). Powers `make dev` — a
|
|
50
|
+
long-running local server connected via
|
|
51
|
+
`claude mcp add … --transport http http://127.0.0.1:8000/mcp` — and
|
|
52
|
+
the eventual remote-container deployment.
|
|
53
|
+
- **Logging routed to stderr** per the MCP spec (stdio servers' logging
|
|
54
|
+
surface). FastMCP banner and its version-check call to
|
|
55
|
+
`pypi.org/pypi/fastmcp/json` are suppressed so deployed-server stderr
|
|
56
|
+
stays ours.
|
|
57
|
+
- **PyPI release pipeline** at
|
|
58
|
+
[`.github/workflows/release.yml`](.github/workflows/release.yml): tag
|
|
59
|
+
push (`X.Y.Z`) builds the wheel, verifies the tag matches
|
|
60
|
+
`pyproject.version`, runs tests, and publishes to PyPI via OIDC-based
|
|
61
|
+
Trusted Publishing. No API tokens or repo secrets. Process docs:
|
|
62
|
+
[`docs/release.md`](docs/release.md).
|
|
63
|
+
- **Maintainer spec-refresh pipeline**: `make specs VERSION=X.Y.Z`
|
|
64
|
+
shallow-clones `portainer/portainer-api-docs` (SSH default,
|
|
65
|
+
`UPSTREAM_REPO=` override) into `spec/upstream/` and runs
|
|
66
|
+
`spec/patch_spec.py` against the requested EE YAML.
|
|
67
|
+
[`spec/patch_spec.py`](spec/patch_spec.py) applies workarounds for
|
|
68
|
+
known upstream spec defects (excluded operations, `/websocket/*`
|
|
69
|
+
paths, malformed enums, YAML tab/`=`-tag defects).
|
|
70
|
+
- **Test suite + CI**: 41 tests under [`tests/`](tests/) covering the
|
|
71
|
+
pure-data surface (spec patcher, shaping, proxy validators). CI runs
|
|
72
|
+
`uv sync --frozen` + `uv run pytest` on push to `main` and every PR.
|
|
73
|
+
- **Hygiene skill** at
|
|
74
|
+
[`skills/portainer-mcp-hygiene/`](skills/portainer-mcp-hygiene/) —
|
|
75
|
+
guidance for MCP clients on when to project with `select`, where the
|
|
76
|
+
heavy fields live, and how to handle non-JSON proxy responses.
|
|
77
|
+
- **FastMCP pin** at `>=3.3,<4` (the `OpenAPIProvider` import path used
|
|
78
|
+
here only exists on 3.x).
|
|
79
|
+
- **Versioning policy** at [`docs/versioning.md`](docs/versioning.md):
|
|
80
|
+
major+minor pins to Portainer's API version; patch slot is the MCP
|
|
81
|
+
server's own.
|
|
82
|
+
|
|
83
|
+
### Known gaps
|
|
84
|
+
|
|
85
|
+
- **CE coverage** is best-effort. The embedded spec is EE; CE is a
|
|
86
|
+
subset and operations missing from CE surface as 404s at call time.
|
|
87
|
+
- **Remote container deployment** (HTTP transport + auth) is not yet
|
|
88
|
+
shipped. The transport switch and `make dev` workflow lay the
|
|
89
|
+
groundwork; auth and a Dockerfile come after PyPI lands.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project
|
|
6
|
+
|
|
7
|
+
MCP server for Portainer, distributed on PyPI as `mcp-portainer`. The tool
|
|
8
|
+
surface is generated from Portainer's EE OpenAPI spec at startup via
|
|
9
|
+
`FastMCP.from_openapi`, with a small filter + response-shaping layer applied
|
|
10
|
+
uniformly. Two hand-written escape-hatch tools (`docker_proxy`,
|
|
11
|
+
`kubernetes_proxy`) forward arbitrary paths the spec doesn't enumerate.
|
|
12
|
+
|
|
13
|
+
Python ≥ 3.11. `uv` is the package manager — there is no `pip`/`poetry`
|
|
14
|
+
workflow. Source layout: `src/portainer_mcp/`.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv sync # install deps from uv.lock
|
|
20
|
+
uv run pytest # run the full test suite
|
|
21
|
+
uv run pytest tests/test_proxy.py # one file
|
|
22
|
+
uv run pytest -k select_unwraps # one test by name
|
|
23
|
+
make dev # local HTTP server via uv + .env (port 8000)
|
|
24
|
+
make specs VERSION=2.41.1 # refresh src/portainer_mcp/data/portainer-patched.yaml
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`make dev` requires `.env` (copy from `.env.example`). It runs the server
|
|
28
|
+
over HTTP at `127.0.0.1:8000` so you can iterate without restarting an MCP
|
|
29
|
+
client — the client (added with `claude mcp add portainer-dev --transport
|
|
30
|
+
http http://127.0.0.1:8000/mcp`) reconnects automatically after a ctrl-c +
|
|
31
|
+
`make dev`.
|
|
32
|
+
|
|
33
|
+
Lint/format: none configured. CI runs only `uv sync --frozen && uv run
|
|
34
|
+
pytest` (see `.github/workflows/ci.yml`).
|
|
35
|
+
|
|
36
|
+
## Architecture
|
|
37
|
+
|
|
38
|
+
Read [`docs/architecture.md`](docs/architecture.md) for the full picture.
|
|
39
|
+
Key things to internalise before changing code:
|
|
40
|
+
|
|
41
|
+
- **`server.py:build_server()` is the wiring point.** It loads the bundled
|
|
42
|
+
spec, builds the httpx client (carrying `X-API-KEY`), constructs
|
|
43
|
+
`RouteMap`s from the resolved profile tags, instantiates FastMCP, then
|
|
44
|
+
registers proxy tools, adds `SelectArgTransform`, and finally adds
|
|
45
|
+
`ResponseCapMiddleware`. Order matters — the transform must run before
|
|
46
|
+
the middleware so every tool exposes `select`.
|
|
47
|
+
- **One `RouteMap` per tag.** FastMCP intersects multi-tag `RouteMap(tags=…)`
|
|
48
|
+
(it's all-of, not any-of), so we emit one `RouteMap` per allowed tag and
|
|
49
|
+
union the matches. Don't collapse them into a single multi-tag map.
|
|
50
|
+
- **`select` is universal.** `SelectArgTransform` (`shaping.py`) wraps
|
|
51
|
+
every tool with an optional JMESPath `select` parameter, including the
|
|
52
|
+
two hand-written proxy tools (their existing `select` arg makes
|
|
53
|
+
`_has_select` skip re-wrapping them). After registration, `build_server`
|
|
54
|
+
asserts every tool exposes `select` and raises at startup if any are
|
|
55
|
+
missing — keep that invariant.
|
|
56
|
+
- **Response cap sits below Claude Code's MCP output cap.** Default
|
|
57
|
+
`PORTAINER_MAX_RESPONSE_CHARS=50_000` is sized so our truncation hint
|
|
58
|
+
(which names `select` with examples) reaches the model before Claude
|
|
59
|
+
Code's own ~62k-char cap triggers its generic "saved to file" handling.
|
|
60
|
+
When truncation fires, `structured_content` is also cleared so the model
|
|
61
|
+
can't read around the cap.
|
|
62
|
+
- **JMESPath unwrap for non-dict responses.** FastMCP wraps list/scalar
|
|
63
|
+
OpenAPI responses as `{"result": …}` to fit MCP's structured-content
|
|
64
|
+
schema. `_select_wrapper` unwraps that single-key envelope before
|
|
65
|
+
projecting, so callers write `[].Id` rather than `result[].Id`.
|
|
66
|
+
|
|
67
|
+
## Spec generation
|
|
68
|
+
|
|
69
|
+
The bundled spec lives at `src/portainer_mcp/data/portainer-patched.yaml`
|
|
70
|
+
and is loaded via `importlib.resources` (so it's read from the wheel in
|
|
71
|
+
production, not relative paths). To regenerate:
|
|
72
|
+
|
|
73
|
+
1. `make specs VERSION=<portainer-version>` — clones/refreshes
|
|
74
|
+
`spec/upstream/` (sparse, single-version), then runs `spec/patch_spec.py`.
|
|
75
|
+
2. `patch_spec.py` drops structurally broken operations (see
|
|
76
|
+
`EXCLUDED_OPERATION_IDS`), strips `/websocket/*` paths, normalises a
|
|
77
|
+
few malformed `enum` blocks, and rewrites stray tabs. Extend those
|
|
78
|
+
constants when the upstream spec ships new defects — don't hand-edit
|
|
79
|
+
`portainer-patched.yaml`.
|
|
80
|
+
|
|
81
|
+
## Versioning
|
|
82
|
+
|
|
83
|
+
Tag format `<portainer-major>.<portainer-minor>.<mcp-patch>` — major+minor
|
|
84
|
+
mirrors the Portainer API target; patch is the MCP server's. **The minor
|
|
85
|
+
only moves when the embedded spec moves.** Refactors, profile additions,
|
|
86
|
+
new proxy tools, shaping changes — all patch. See
|
|
87
|
+
[`docs/versioning.md`](docs/versioning.md) and [`docs/release.md`](docs/release.md)
|
|
88
|
+
(release is OIDC-driven via PyPI Trusted Publishing on tag push).
|
|
89
|
+
|
|
90
|
+
## Profiles
|
|
91
|
+
|
|
92
|
+
Spec exposes ~380 operations across 40+ tags; profiles in `profiles.py`
|
|
93
|
+
bundle them. `PORTAINER_PROFILES` (default `BASE,DOCKER,KUBERNETES`)
|
|
94
|
+
selects which to enable; `PORTAINER_TAGS_EXTRA` appends raw tags as an
|
|
95
|
+
escape hatch. `PORTAINER_PROFILES=ALL` disables the tag filter entirely.
|
|
96
|
+
Unknown profile names fail at startup; unknown extras log a warning and
|
|
97
|
+
pass through (they just don't match anything). Full per-profile tag list
|
|
98
|
+
and orphan-tag inventory in [`docs/profiles.md`](docs/profiles.md).
|
|
99
|
+
|
|
100
|
+
## Tests
|
|
101
|
+
|
|
102
|
+
`pytest` with `asyncio_mode = "auto"` (see `pyproject.toml`). Tests live
|
|
103
|
+
in `tests/` and import the spec patcher via `tests/conftest.py` which
|
|
104
|
+
prepends `spec/` to `sys.path` (it's a script dir, not a package).
|
|
105
|
+
|
|
106
|
+
## Conventions
|
|
107
|
+
|
|
108
|
+
- This repo follows a YAGNI / minimal-surface style: no speculative
|
|
109
|
+
scaffolding, no literal-guard tests, no refactor-for-testability without
|
|
110
|
+
independent merit. Trust internal code, validate at boundaries.
|
|
111
|
+
- Comments are sparse and exist to explain *why* (hidden constraints,
|
|
112
|
+
surprising behaviour, workarounds for spec defects). Don't add WHAT
|
|
113
|
+
comments — identifiers carry that.
|
|
114
|
+
- Env-var flags are parsed via `_env_flag` in `server.py`; falsy values
|
|
115
|
+
are `0`, `false`, `False`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Portainer.io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
UPSTREAM_REPO ?= git@github.com:portainer/portainer-api-docs.git
|
|
2
|
+
UPSTREAM_DIR := spec/upstream
|
|
3
|
+
|
|
4
|
+
.PHONY: specs dev
|
|
5
|
+
|
|
6
|
+
# Refresh src/portainer_mcp/data/portainer-patched.yaml from upstream
|
|
7
|
+
# Usage: make specs VERSION=2.41.1
|
|
8
|
+
specs:
|
|
9
|
+
@if [ -z "$(VERSION)" ]; then \
|
|
10
|
+
echo "VERSION is required, e.g. make specs VERSION=2.41.1" >&2; \
|
|
11
|
+
exit 1; \
|
|
12
|
+
fi
|
|
13
|
+
@if [ -d $(UPSTREAM_DIR)/.git ]; then \
|
|
14
|
+
git -C $(UPSTREAM_DIR) fetch --depth=1 origin HEAD && \
|
|
15
|
+
git -C $(UPSTREAM_DIR) reset --hard FETCH_HEAD; \
|
|
16
|
+
else \
|
|
17
|
+
git clone --depth=1 --filter=blob:none --no-checkout \
|
|
18
|
+
$(UPSTREAM_REPO) $(UPSTREAM_DIR); \
|
|
19
|
+
fi
|
|
20
|
+
git -C $(UPSTREAM_DIR) sparse-checkout set --no-cone /versions/ee/$(VERSION).yaml
|
|
21
|
+
git -C $(UPSTREAM_DIR) checkout
|
|
22
|
+
uv run python spec/patch_spec.py $(UPSTREAM_DIR)/versions/ee/$(VERSION).yaml
|
|
23
|
+
|
|
24
|
+
# Local dev server (HTTP transport). One-time setup:
|
|
25
|
+
# 1. cp .env.example .env and fill in PORTAINER_URL + PORTAINER_API_KEY
|
|
26
|
+
# 2. claude mcp add portainer-dev --transport http http://127.0.0.1:8000/mcp
|
|
27
|
+
# Then iterate: edit code, ctrl-c, make dev again. Claude reconnects automatically.
|
|
28
|
+
dev:
|
|
29
|
+
PORTAINER_MCP_TRANSPORT=http uv run --env-file .env portainer-mcp
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-portainer
|
|
3
|
+
Version: 2.41.0
|
|
4
|
+
Summary: Portainer MCP server — manage Docker, Kubernetes, stacks, and Helm via the Model Context Protocol.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: fastmcp<4,>=3.3
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: jmespath>=1.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Portainer MCP
|
|
2
|
+
|
|
3
|
+
MCP server for Portainer, generated from the Portainer OpenAPI spec via
|
|
4
|
+
[FastMCP](https://github.com/PrefectHQ/fastmcp).
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
Exposes Portainer's REST API as MCP tools — list environments, manage
|
|
9
|
+
Docker containers and stacks, query Kubernetes resources, run Helm
|
|
10
|
+
releases. Two escape-hatch tools (`docker_proxy`, `kubernetes_proxy`)
|
|
11
|
+
forward arbitrary paths to the underlying Docker/K8s APIs for endpoints
|
|
12
|
+
the spec doesn't enumerate.
|
|
13
|
+
|
|
14
|
+
**Status: in development.** Tool names, env vars, and defaults can
|
|
15
|
+
change between releases. Pin loosely (see
|
|
16
|
+
[Version compatibility](#version-compatibility)) to pick up MCP-only
|
|
17
|
+
fixes without surprise.
|
|
18
|
+
|
|
19
|
+
Architecture overview: [`docs/architecture.md`](docs/architecture.md).
|
|
20
|
+
|
|
21
|
+
## Getting started
|
|
22
|
+
|
|
23
|
+
The server is distributed on PyPI as `mcp-portainer`. MCP clients launch it as
|
|
24
|
+
a subprocess via [`uvx`](https://docs.astral.sh/uv/), so `uv` must be on
|
|
25
|
+
`PATH` — see [the uv install docs](https://docs.astral.sh/uv/getting-started/installation/).
|
|
26
|
+
|
|
27
|
+
Generate an API key in Portainer under **My Account → Access tokens**, then
|
|
28
|
+
register the server with Claude Code:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude mcp add portainer \
|
|
32
|
+
-e PORTAINER_URL=https://portainer.example.com \
|
|
33
|
+
-e PORTAINER_API_KEY=ptr_xxxxxxxxxxxxxxxx \
|
|
34
|
+
-- uvx --from "mcp-portainer~=2.41.0" mcp-portainer
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`~=2.41.0` picks up MCP-only patch fixes against the same Portainer minor —
|
|
38
|
+
see [Version compatibility](#version-compatibility) for the policy.
|
|
39
|
+
|
|
40
|
+
For other clients, see [`docs/distribution/`](docs/distribution/). See
|
|
41
|
+
[Configuration](#configuration) for optional knobs.
|
|
42
|
+
|
|
43
|
+
Contribution are welcome for other client instructions !
|
|
44
|
+
|
|
45
|
+
## Version compatibility
|
|
46
|
+
|
|
47
|
+
**Match your server's minor to your Portainer minor.** The
|
|
48
|
+
major+minor tracks the Portainer API version the embedded spec targets.
|
|
49
|
+
|
|
50
|
+
| Server version | Portainer (CE / EE) |
|
|
51
|
+
| -------------- | ------------------- |
|
|
52
|
+
| `2.41.x` | `2.41.x` |
|
|
53
|
+
|
|
54
|
+
- EE spec only — CE is a subset and works on a best-effort basis.
|
|
55
|
+
- Full policy: [`docs/versioning.md`](docs/versioning.md).
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
All knobs are environment variables. Only `PORTAINER_URL` and
|
|
60
|
+
`PORTAINER_API_KEY` are required.
|
|
61
|
+
|
|
62
|
+
| Env var | Default | Effect |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `PORTAINER_URL` | — | **Required.** Portainer base URL. |
|
|
65
|
+
| `PORTAINER_API_KEY` | — | **Required.** Portainer API key. |
|
|
66
|
+
| `PORTAINER_PROFILES` | `BASE,DOCKER,KUBERNETES` | Tag bundles to enable. `ALL` disables the filter. |
|
|
67
|
+
| `PORTAINER_TAGS_EXTRA` | _empty_ | Extra tags appended to the profile union (escape hatch). |
|
|
68
|
+
| `PORTAINER_READ_ONLY` | `0` | `1` restricts to `GET`/`HEAD` operations. |
|
|
69
|
+
| `PORTAINER_NO_PROXY` | `0` | `1` skips `docker_proxy` / `kubernetes_proxy`. |
|
|
70
|
+
| `PORTAINER_TLS_VERIFY` | `1` | `0` skips TLS verification (Portainer instance using self-signed certs). |
|
|
71
|
+
| `PORTAINER_MAX_RESPONSE_CHARS` | `50000` | Response truncation cap. Size to ~80% of your MCP client's output ceiling. |
|
|
72
|
+
| `PORTAINER_MCP_LOG_LEVEL` | `INFO` | One of `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. Logs go to stderr. |
|
|
73
|
+
| `PORTAINER_MCP_TRANSPORT` | `stdio` | `stdio` (default) or `http`. `http` binds a local server for dev / remote deployment. |
|
|
74
|
+
| `PORTAINER_MCP_HTTP_HOST` | `127.0.0.1` | Bind host when `PORTAINER_MCP_TRANSPORT=http`. |
|
|
75
|
+
| `PORTAINER_MCP_HTTP_PORT` | `8000` | Bind port when `PORTAINER_MCP_TRANSPORT=http`. |
|
|
76
|
+
|
|
77
|
+
Advanced profile setup — per-profile tag lists, orphan tags, read-only
|
|
78
|
+
semantics — see [`docs/profiles.md`](docs/profiles.md).
|