portainer-mcp: Instructions file for Claude Code

CLAUDE.md

portainer-mcp CLAUDE.md is an instructions file for Claude Code from portainer/portainer-mcp. It costs 4,637 tokens per session, scanned A, original, MIT.

A repository guide for Claude Code, an AI coding assistant, working on a Portainer MCP server. It documents the project structure, commands, architecture, and specification generation.

In plain words
What is it for?
Use it when developing or maintaining the Portainer MCP server, including installing dependencies, running tests, starting the local server, or refreshing its API specification.
Why use it?
It gives the coding assistant the project-specific context needed to make changes and run the right checks. This reduces guesswork about setup, testing, and how the server is built.

Instructions file for Claude Code

Written for Claude Code: the file is CLAUDE.md. Also seen: mentions CLAUDE.md; mentions Claude Code.

This is portainer/portainer-mcp's own configuration. It tells Claude Code how to work on portainer-mcp itself, so it is not a mod to install elsewhere. Copy it as a starting point and replace the rules that are about this project. Everything portainer-mcp configures →

Reuse

Borrowing it

Nothing to install: this file belongs to portainer/portainer-mcp. Take a copy, put it at the same path in your own repository, and replace the rules that are about this project with yours.

Copy the file
curl -O https://raw.githubusercontent.com/portainer/portainer-mcp/main/CLAUDE.md
Clone the repo
git clone --depth 1 https://github.com/portainer/portainer-mcp

Made for: Claude Code.

Wrote this? Show the measurements

A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.

agentmods badge for portainer-mcp CLAUDE.md

README.md
[![agentmods](https://agentmods.dev/badge/instructions/portainer/portainer-mcp/claude-md.svg)](https://agentmods.dev/instructions/portainer/portainer-mcp/claude-md)
Your own site
<a href="https://agentmods.dev/instructions/portainer/portainer-mcp/claude-md"><img src="https://agentmods.dev/badge/instructions/portainer/portainer-mcp/claude-md.svg" alt="Measured on agentmods" height="20"></a>
Per session 4,637 This file is loaded in full into every session.
When invoked 4,637 The same file — it is already loaded in full.
Security scan A 0 findings. A grade says what 26 rules found in the file — not that it is safe.
Origin original No closer match found in the catalogue.
Token cost

What it costs to keep this loaded

Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.

ModelPer sessionOnce invoked
Fable 5.1 $0.04637 $0.04637
Opus 5 $0.02318 $0.02318
Sonnet 5 $0.00927 $0.00927
Haiku 4.5 $0.00464 $0.00464

Measured 2d ago against content hash 5b485719863c, method: parsed. Prices are Anthropic first-party input rates as of 2026-09-07, from the pricing page.

Security

Grade A, and why

portainer-mcp CLAUDE.md scanned grade A with 0 findings against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured 2d ago.

A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.

Nothing flagged

None of the 26 patterns this scan looks for appear in this file: no shell pipes, no recursive deletes, no credential paths, no hidden text, no instruction-override or anti-refusal phrasing, no agent-config snooping. That is not a guarantee, it is the absence of the things that are checkable.

CLAUDE.md · 301 lines

How it starts

The opening of the file, as written. The whole thing — 301 lines — stays where its author put it; the contents beside it link to each section on GitHub.

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

MCP server for Portainer, distributed on PyPI as mcp-portainer. The tool surface is generated from Portainer's EE OpenAPI spec at startup via FastMCP.from_openapi, with a small filter + response-shaping layer applied uniformly. Two hand-written escape-hatch tools (docker_proxy, kubernetes_proxy) forward arbitrary paths the spec doesn't enumerate.

Python ≥ 3.11. uv is the package manager — there is no pip/poetry workflow. Source layout: src/portainer_mcp/.

Commands

uv sync                              # install deps from uv.lock
uv run pytest                        # run the full test suite
uv run pytest tests/test_proxy.py    # one file
uv run pytest -k select_unwraps      # one test by name
make dev                             # local HTTP server via uv + .env (port 17717)
make specs VERSION=2.41.1            # refresh src/portainer_mcp/data/portainer-patched.yaml

make dev requires .env (copy from .env.example). It runs the server over HTTP at 127.0.0.1:17717 so you can iterate without restarting an MCP client — the client (added with claude mcp add portainer-dev --transport http http://127.0.0.1:17717/mcp) reconnects automatically after a ctrl-c + make dev.

Lint/format: none configured. CI runs only uv sync --frozen && uv run pytest (see .github/workflows/ci.yml).

Architecture

Read docs/architecture.md for the full picture. Key things to internalise before changing code:

  • server.py:build_server() is the wiring point. It loads the bundled spec, builds the httpx client (carrying X-API-KEY), constructs RouteMaps from the resolved profile tags, instantiates FastMCP, then registers proxy tools, adds SelectArgTransform, and finally adds ResponseCapMiddleware. Order matters — the transform must run before the middleware so every tool exposes select.
  • One RouteMap per tag. FastMCP intersects multi-tag RouteMap(tags=…) (it's all-of, not any-of), so we emit one RouteMap per allowed tag and union the matches. Don't collapse them into a single multi-tag map.
  • select is universal. SelectArgTransform (shaping.py) wraps every tool with an optional JMESPath select parameter, including the two hand-written proxy tools (their existing select arg makes _has_select skip re-wrapping them). After registration, build_server asserts every tool exposes select and raises at startup if any are missing — keep that invariant.
  • Response cap sits below Claude Code's MCP output cap. Default PORTAINER_MAX_RESPONSE_CHARS=50_000 is sized so our truncation hint (which names select with examples) reaches the model before Claude Code's own ~62k-char cap triggers its generic "saved to file" handling. When truncation fires, structured_content is also cleared so the model can't read around the cap.
  • JMESPath unwrap for non-dict responses. FastMCP wraps list/scalar OpenAPI responses as {"result": …} to fit MCP's structured-content schema. _select_wrapper unwraps that single-key envelope before projecting, so callers write [].Id rather than result[].Id.
  • Empty JSON bodies go out as {}. json_body.install() swaps every OpenAPI tool's request director for JsonBodyDirector, which sends {} when a route declares an application/json body and the model supplied no body fields. FastMCP would otherwise send no body at all, and Portainer's bare json.Decoder answers 400 … EOF (a no-arg StackGitRedeploy could never succeed). It runs right after from_openapi, before SelectArgTransform wraps the tools; routes without a declared body are untouched.
  • Env values redacted before projection. redaction.redact_envs() walks the parsed response in _select_wrapper and in the proxy tools before JMESPath select runs — so select="Env[0].value" lands on the [REDACTED] sentinel rather than the real value. The walker is field-name driven (env / envvars, case-insensitive) and handles Shapes A/F/G (list of {name, value} dicts) and Shape C (Docker "KEY=VAL" strings); K8s valueFrom references are preserved. Disabled with PORTAINER_EXPOSE_ENV_VALUES=1; logged at startup so the posture is greppable. When redaction fires, the response carries a one-line summary TextContent naming the env var.
  • HTTP transport requires a bearer token. auth.py defines StaticBearerVerifier (a fastmcp.server.auth.TokenVerifier subclass using hmac.compare_digest); build_server() wires a verifier into FastMCP.from_openapi(..., auth=…) only when transport=http. Stdio ignores PORTAINER_MCP_AUTH_TOKEN. Strict validation at startup (min 32 chars, ASCII printable, no whitespace) — loud-fail like the unknown-profile check. Don't relax this for "convenience"; the strict rule eliminates the make-dev-no-token footgun.
  • Auth posture is an enum: gate token XOR trust-proxy. auth_posture. resolve() (mirrors tls.resolve_posture) runs before the verifier is built. PORTAINER_MCP_TRUST_PROXY_AUTH=1 serves identity-aware proxies that own Authorization (issue #76, Pomerium MCP mode): the bearer value is ignored (auth.TrustedProxyVerifier; _EnsureBearerMiddleware injects a placeholder when the proxy strips the header, because the SDK 401s header-less requests before verify_token) and the gate compare is replaced by per-request proxy attestation. Two shapes, because uvicorn rewrites scope["client"] from XFF for trusted peers so socket-peer and forwarded-header trust are mutually exclusive signals: inherited (requires TRUST_PROXY_TLS=1; attestation is scheme == "https", which only a FORWARDED_ALLOW_IPS peer can produce since no cert is held) and socket peer (PORTAINER_MCP_TRUSTED_PROXY_AUTH_IPS + server-terminated TLS; resolve() emits proxy_headers: False so the peer stays raw). Hard-fails: both postures declared, neither, trust + plaintext opt-out, wildcard in the effective allowlist (* or zero-prefix CIDR like 0.0.0.0/0), TLS_CERT alongside the inherited shape (a server-held cert lets any direct connection present https, voiding the attestation), TRUSTED_PROXY_AUTH_IPS combined with TRUST_PROXY_TLS/ FORWARDED_ALLOW_IPS or set without the trust flag, missing ALLOWED_HOSTS on a non-loopback bind. PeerMatcher unmaps IPv4-mapped IPv6 peers (dual-stack binds). The per-user X-Portainer-API-Key floor is unchanged — trust-proxy drops the gate, never authentication. New audit outcomes untrusted_scheme / untrusted_peer; records carry auth_posture: "trust_proxy".
  • HTTP is per-user passthrough, not a shared upstream key. Over HTTP the verifier is auth.PassthroughVerifier (subclass of StaticBearerVerifier), and PORTAINER_API_KEY is not loaded — it's the stdio-only credential, and build_server() hard-fails if it's set under http (a misconfiguration, not a silent fallback). Two layered checks run inside one verify_token so a failure 401s before any tool dispatch: (1) the gate token in Authorization is constant-time compared by the parent; (2) the caller's own key in the separate X-Portainer-API-Key header is validated against /users/me (passthrough.validate, positive-only ValidationCache keyed by the SHA-256 of the key, TTL PORTAINER_MCP_AUTH_CACHE_TTL default 60). The validated key is injected upstream as X-API-KEY by the passthrough.inject_api_key httpx request hook, which reads only the in-flight request (so one caller can't borrow another's key) and fails closed — it raises rather than ever sending a keyless upstream call. The two headers carry distinct credentials (gate vs per-user key), so the verified token and the forwarded token are never the same value; the httpx client under http therefore carries no baked X-API-KEY (stdio still does). Audit outcomes gain no_user_key / invalid_user_key. ok fires only on a validation (a cache miss that hits /users/me), attributed with portainer_user_id/username — so it marks a validation event (~one per key per TTL window), not every admitted request; cache hits admit silently (validate() returns (identity, validated_now) so the verifier knows which). The failure outcomes are uncached and fire per request. The per-user key itself is never logged (regression-tested). The structured request log adds the tool name on a tools/call (the bare method is only ever tools/call). The cache TTL is a perf/DoS knob, not the authz boundary (Portainer rejects a revoked key on every real call); never negative-cache (it would lock out a fresh key).
  • Two HTTP hardening layers stack on top of the bearer. Wired in build_server() + main(): a contextualised StructuredLoggingMiddleware applies to every transport; http_security.DNSRebindingMiddleware is passed to server.run(..., middleware=[…]) only for http. Starlette appends user middleware after the auth backend, so DNS-rebinding fires inside the auth chain — bearer-auth runs first, then the Host check. Practical impact is small (the audit record may include rebinding-probe attempts that present a valid token; failed-auth attempts hit 401 before any Host check), but don't assume the Host reject precedes bearer-auth when reading audit logs. StaticBearerVerifier.verify_token emits a structured audit record on every attempt under the portainer_mcp.audit sub-logger — never include the attempted token in those records. In-process rate limiting was intentionally dropped: at numbers that didn't impede legitimate clients it didn't bound blast radius either, and a reverse proxy is the right place for that control.
  • Per-request context is read from the live HTTP request. request_context.snapshot() returns client_ip, user_agent, and the MCP Mcp-Session-Id from fastmcp.server.dependencies.get_http_request(). Both the audit log (in verify_token) and the FastMCP-layer structured request log (_ContextualStructuredLogging) call it. Custom outer ContextVars don't work here: MCP's streamable-HTTP session manager dispatches each JSON-RPC message into a long-lived task whose context was captured at session-creation time, so subsequent requests would log the stale initialize-time values. get_http_request() reads through MCP SDK's per-message request_ctx instead, which is current. FastMCP's own RequestContextMiddleware is inserted at position 0 of the middleware stack (fastmcp.server.http.create_base_app), so it runs outside the bearer-auth middleware and get_http_request() is already populated by the time verify_token executes — no custom prepend needed. If a future FastMCP refactor moves that insertion or the auth backend grows to read the request before fastmcp's middleware runs, the audit log will silently lose its context fields; re-add a small ASGI middleware via StaticBearerVerifier.get_middleware() if that happens. With a single shared bearer the audit deliberately omits token_fp (it would be a constant); session_id is what actually joins an audit row to its request rows.
  • DNS-rebinding rejections carry the env var name back to the operator. _enrich rewrites the SDK's bare 421 body to include PORTAINER_MCP_ALLOWED_HOSTS; misconfig_warning logs a startup WARNING when the bind host is non-loopback while the allowlist is still the localhost defaults. The two together turn the "I deployed it and it 421s" first-deploy moment into a self-diagnosing error — keep the env-var name in both signals when refactoring. The Origin allowlist is hardcoded (no env var): programmatic MCP clients omit Origin and pass through, the local Inspector is covered by the localhost defaults, and the MCP spec MUSTs the check itself, not the configurability. Don't re-add an ALLOWED_ORIGINS env var unless a real browser-hosted client use case shows up.
  • TLS posture hard-fails on a non-loopback bind. tls.resolve_posture() in main() refuses to boot unless the operator declares one of three shapes — server-terminated cert (PORTAINER_MCP_TLS_CERT/_TLS_KEY → uvicorn ssl_certfile/ssl_keyfile), proxy attestation (PORTAINER_MCP_TRUST_PROXY_TLS=1 + ..._FORWARDED_ALLOW_IPSforwarded_allow_ips), or the one loud plaintext opt-out (PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP=1). Loopback binds are exempt (dev). Both encrypted shapes converge on scope["scheme"] == "https", enforced by TLSRequiredMiddleware as a backstop. Critically, that middleware is installed via PassthroughVerifier.get_middleware() (add_pre_auth_middleware), not the server.run(middleware=[…]) list — the list runs after the auth backend, but the TLS check must run before it so a plaintext request is rejected before the per-user key is validated and forwarded upstream (amplification). The loopback exemption is keyed on the bind host at install time, never the per-request client IP. The plaintext opt-out also flips auth.mark_insecure_transport(), so every audit record carries insecure_transport: true. Self-signed certs WARN, never block — the server only holds the leaf and can't judge true trust, so a hard-fail would be inconsistent (it'd miss internal-CA certs) and would break the legitimate mount-your-own-cert homelab path. No auto-self-signed mode: real MCP clients reject self-signed certs and don't pin by fingerprint.
  • Log shape is selectable. PORTAINER_MCP_LOG_FORMAT=text|json (default text, container image overrides to json). The json formatter merges records whose msg is itself a JSON object into the envelope, so audit and request records become first-class fields. Keep this property when adding new structured loggers — emit json.dumps({...}) as the message and the formatter does the right thing in both modes.

Read the full file on GitHub · 301 lines

Changes

What this file has done since we first saw it

Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.

  1. 2d ago Changed · +14 lines · +233 tokens per session 5b485719863c
  2. 8d ago First seen · 287 lines · 4,404 tokens per session scan A dd01e214590d

Subscribe to this mod's changes

portainer-mcp CLAUDE.md is an instructions file published in the GitHub repository portainer/portainer-mcp (227 stars, last pushed 5d ago), licensed MIT. It adds 4,637 tokens to every session, about $0.0232 per session on Opus 5. A static security scan graded it A with 0 findings. No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-30.

Related

Other instructions, from other repositories

codex AGENTS.md

AGENTS.md instructions for openai/codex, covering rust/codex-rs, the codex-core crate, code review rules, crate api surface and model visible context.

openai/codex · 5,182 tokens

vscode buildNext.instructions.md

Working notes and architecture documentation for the new esbuild-based build system in build/next. Use when making changes to the new build pipeline (transpile/bundle commands, NLS plugin, source-map handling, resource copying, or self-hosting watch tasks).

microsoft/vscode · 6,785 tokens

next.js AGENTS.md

AGENTS.md instructions for vercel/next.js, covering next.js development guide, codebase structure, monorepo overview, core package: packages/next and other important packages.

vercel/next.js · 7,296 tokens

langchain AGENTS.md

AGENTS.md instructions for langchain-ai/langchain, covering global development guidelines for the langchain monorepo, corridor security analysis, project architecture and context, monorepo structure and development tools & commands.

langchain-ai/langchain · 4,469 tokens

vscode oss-third-party-notices.instructions.md

Instructions for microsoft/vscode, covering vs code oss third-party-notices pipeline, architecture, pipeline flow in ci, applying the notice (cutover) and fallback chain (never fail the build).

microsoft/vscode · 5,001 tokens

spec-kit AGENTS.md

AGENTS.md instructions for github/spec-kit, covering agents.md, about spec kit and specify, quickstart — add a new integration in 5 steps, integration architecture and integrationmanifest — file tracking.

github/spec-kit · 7,104 tokens