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.
curl -O https://raw.githubusercontent.com/portainer/portainer-mcp/main/CLAUDE.mdgit clone --depth 1 https://github.com/portainer/portainer-mcpWrote 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.
[](https://agentmods.dev/instructions/portainer/portainer-mcp/claude-md)<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>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.
| Model | Per session | Once 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 |
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.
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 (carryingX-API-KEY), constructsRouteMaps from the resolved profile tags, instantiates FastMCP, then registers proxy tools, addsSelectArgTransform, and finally addsResponseCapMiddleware. Order matters — the transform must run before the middleware so every tool exposesselect.- One
RouteMapper tag. FastMCP intersects multi-tagRouteMap(tags=…)(it's all-of, not any-of), so we emit oneRouteMapper allowed tag and union the matches. Don't collapse them into a single multi-tag map. selectis universal.SelectArgTransform(shaping.py) wraps every tool with an optional JMESPathselectparameter, including the two hand-written proxy tools (their existingselectarg makes_has_selectskip re-wrapping them). After registration,build_serverasserts every tool exposesselectand 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_000is sized so our truncation hint (which namesselectwith examples) reaches the model before Claude Code's own ~62k-char cap triggers its generic "saved to file" handling. When truncation fires,structured_contentis 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_wrapperunwraps that single-key envelope before projecting, so callers write[].Idrather thanresult[].Id. - Empty JSON bodies go out as
{}.json_body.install()swaps every OpenAPI tool's request director forJsonBodyDirector, which sends{}when a route declares anapplication/jsonbody and the model supplied no body fields. FastMCP would otherwise send no body at all, and Portainer's barejson.Decoderanswers400 … EOF(a no-argStackGitRedeploycould never succeed). It runs right afterfrom_openapi, beforeSelectArgTransformwraps the tools; routes without a declared body are untouched. - Env values redacted before projection.
redaction.redact_envs()walks the parsed response in_select_wrapperand in the proxy tools before JMESPathselectruns — soselect="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); K8svalueFromreferences are preserved. Disabled withPORTAINER_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.pydefinesStaticBearerVerifier(afastmcp.server.auth.TokenVerifiersubclass usinghmac.compare_digest);build_server()wires a verifier intoFastMCP.from_openapi(..., auth=…)only when transport=http. Stdio ignoresPORTAINER_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()(mirrorstls.resolve_posture) runs before the verifier is built.PORTAINER_MCP_TRUST_PROXY_AUTH=1serves identity-aware proxies that ownAuthorization(issue #76, Pomerium MCP mode): the bearer value is ignored (auth.TrustedProxyVerifier;_EnsureBearerMiddlewareinjects a placeholder when the proxy strips the header, because the SDK 401s header-less requests beforeverify_token) and the gate compare is replaced by per-request proxy attestation. Two shapes, because uvicorn rewritesscope["client"]from XFF for trusted peers so socket-peer and forwarded-header trust are mutually exclusive signals: inherited (requiresTRUST_PROXY_TLS=1; attestation isscheme == "https", which only aFORWARDED_ALLOW_IPSpeer can produce since no cert is held) and socket peer (PORTAINER_MCP_TRUSTED_PROXY_AUTH_IPS+ server-terminated TLS; resolve() emitsproxy_headers: Falseso the peer stays raw). Hard-fails: both postures declared, neither, trust + plaintext opt-out, wildcard in the effective allowlist (*or zero-prefix CIDR like0.0.0.0/0),TLS_CERTalongside the inherited shape (a server-held cert lets any direct connection present https, voiding the attestation),TRUSTED_PROXY_AUTH_IPScombined withTRUST_PROXY_TLS/FORWARDED_ALLOW_IPSor set without the trust flag, missingALLOWED_HOSTSon a non-loopback bind.PeerMatcherunmaps IPv4-mapped IPv6 peers (dual-stack binds). The per-userX-Portainer-API-Keyfloor is unchanged — trust-proxy drops the gate, never authentication. New audit outcomesuntrusted_scheme/untrusted_peer; records carryauth_posture: "trust_proxy". - HTTP is per-user passthrough, not a shared upstream key. Over HTTP
the verifier is
auth.PassthroughVerifier(subclass ofStaticBearerVerifier), andPORTAINER_API_KEYis not loaded — it's the stdio-only credential, andbuild_server()hard-fails if it's set under http (a misconfiguration, not a silent fallback). Two layered checks run inside oneverify_tokenso a failure 401s before any tool dispatch: (1) the gate token inAuthorizationis constant-time compared by the parent; (2) the caller's own key in the separateX-Portainer-API-Keyheader is validated against/users/me(passthrough.validate, positive-onlyValidationCachekeyed by the SHA-256 of the key, TTLPORTAINER_MCP_AUTH_CACHE_TTLdefault 60). The validated key is injected upstream asX-API-KEYby thepassthrough.inject_api_keyhttpx 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 bakedX-API-KEY(stdio still does). Audit outcomes gainno_user_key/invalid_user_key.okfires only on a validation (a cache miss that hits/users/me), attributed withportainer_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 thetoolname on atools/call(the baremethodis only evertools/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 contextualisedStructuredLoggingMiddlewareapplies to every transport;http_security.DNSRebindingMiddlewareis passed toserver.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_tokenemits a structured audit record on every attempt under theportainer_mcp.auditsub-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()returnsclient_ip,user_agent, and the MCPMcp-Session-Idfromfastmcp.server.dependencies.get_http_request(). Both the audit log (inverify_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 staleinitialize-time values.get_http_request()reads through MCP SDK's per-messagerequest_ctxinstead, which is current. FastMCP's ownRequestContextMiddlewareis inserted at position 0 of the middleware stack (fastmcp.server.http.create_base_app), so it runs outside the bearer-auth middleware andget_http_request()is already populated by the timeverify_tokenexecutes — 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 viaStaticBearerVerifier.get_middleware()if that happens. With a single shared bearer the audit deliberately omitstoken_fp(it would be a constant);session_idis what actually joins an audit row to its request rows. - DNS-rebinding rejections carry the env var name back to the operator.
_enrichrewrites the SDK's bare 421 body to includePORTAINER_MCP_ALLOWED_HOSTS;misconfig_warninglogs 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. TheOriginallowlist is hardcoded (no env var): programmatic MCP clients omitOriginand 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 anALLOWED_ORIGINSenv var unless a real browser-hosted client use case shows up. - TLS posture hard-fails on a non-loopback bind.
tls.resolve_posture()inmain()refuses to boot unless the operator declares one of three shapes — server-terminated cert (PORTAINER_MCP_TLS_CERT/_TLS_KEY→ uvicornssl_certfile/ssl_keyfile), proxy attestation (PORTAINER_MCP_TRUST_PROXY_TLS=1+..._FORWARDED_ALLOW_IPS→forwarded_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 onscope["scheme"] == "https", enforced byTLSRequiredMiddlewareas a backstop. Critically, that middleware is installed viaPassthroughVerifier.get_middleware()(add_pre_auth_middleware), not theserver.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 flipsauth.mark_insecure_transport(), so every audit record carriesinsecure_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(defaulttext, container image overrides tojson). Thejsonformatter merges records whosemsgis itself a JSON object into the envelope, so audit and request records become first-class fields. Keep this property when adding new structured loggers — emitjson.dumps({...})as the message and the formatter does the right thing in both modes.
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.
- 2d ago Changed · +14 lines · +233 tokens per session 5b485719863c
- 8d ago First seen · 287 lines · 4,404 tokens per session scan A dd01e214590d
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.
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.
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).
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.
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.
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).
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.