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.
Files changed (31) hide show
  1. mcp_portainer-2.41.0/.claude/skills/portainer-mcp-hygiene/SKILL.md +149 -0
  2. mcp_portainer-2.41.0/.env.example +10 -0
  3. mcp_portainer-2.41.0/.github/workflows/ci.yml +17 -0
  4. mcp_portainer-2.41.0/.github/workflows/release-test.yml +37 -0
  5. mcp_portainer-2.41.0/.github/workflows/release.yml +44 -0
  6. mcp_portainer-2.41.0/.gitignore +8 -0
  7. mcp_portainer-2.41.0/CHANGELOG.md +89 -0
  8. mcp_portainer-2.41.0/CLAUDE.md +115 -0
  9. mcp_portainer-2.41.0/LICENSE +9 -0
  10. mcp_portainer-2.41.0/Makefile +29 -0
  11. mcp_portainer-2.41.0/PKG-INFO +11 -0
  12. mcp_portainer-2.41.0/README.md +78 -0
  13. mcp_portainer-2.41.0/docs/architecture.md +87 -0
  14. mcp_portainer-2.41.0/docs/distribution/claude-desktop.md +22 -0
  15. mcp_portainer-2.41.0/docs/profiles.md +98 -0
  16. mcp_portainer-2.41.0/docs/release.md +69 -0
  17. mcp_portainer-2.41.0/docs/versioning.md +27 -0
  18. mcp_portainer-2.41.0/pyproject.toml +34 -0
  19. mcp_portainer-2.41.0/spec/patch_spec.py +89 -0
  20. mcp_portainer-2.41.0/src/portainer_mcp/__init__.py +0 -0
  21. mcp_portainer-2.41.0/src/portainer_mcp/data/portainer-patched.yaml +29616 -0
  22. mcp_portainer-2.41.0/src/portainer_mcp/profiles.py +88 -0
  23. mcp_portainer-2.41.0/src/portainer_mcp/proxy.py +182 -0
  24. mcp_portainer-2.41.0/src/portainer_mcp/server.py +159 -0
  25. mcp_portainer-2.41.0/src/portainer_mcp/shaping.py +168 -0
  26. mcp_portainer-2.41.0/tests/conftest.py +15 -0
  27. mcp_portainer-2.41.0/tests/test_patch_spec.py +121 -0
  28. mcp_portainer-2.41.0/tests/test_profiles.py +83 -0
  29. mcp_portainer-2.41.0/tests/test_proxy.py +80 -0
  30. mcp_portainer-2.41.0/tests/test_shaping.py +62 -0
  31. 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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ spec/upstream/
5
+ logs/
6
+ *.local.md
7
+ .env
8
+ dist/
@@ -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).