I shipped 7 production MCP server Actors in two weeks — here's what the docs don't tell you
The first Actor took most of a day. The seventh took under two hours. The delta wasn't clever abstractions or a better framework. It was the institutional knowledge that accumulates after you've hit every silent failure mode that Apify's documentation doesn't mention.
This post covers the patterns that mattered. All of it works whether you buy anything at the end or not.
The stack: what "MCP Actor on Apify" actually means
Apify's Standby mode lets you run a persistent Python process that accepts HTTP connections. Combine that with FastMCP and you get an MCP server at a stable public URL, billed per tool call rather than per minute. The Standby URL format is:
https://<username-hyphenated>--<actor-name>.apify.actor/mcp
So unbearable-dev--docker-compose-audit.apify.actor/mcp is the live MCP endpoint for my docker-compose audit Actor. Any MCP client — Claude Desktop, Cursor, n8n, the Smithery gateway — can point at it directly.
The basic scaffold is straightforward. The gotchas are not.
Gotcha 1: webServerMcpPath — the silent routing killer
Your actor.json controls how Apify routes MCP traffic to your Standby process. The relevant field is webServerMcpPath:
{
"usesStandbyMode": true,
"webServerMcpPath": "/mcp"
}
If this field is absent, misspelled, or set to anything other than the path your FastMCP server actually mounts at, you'll see the Actor status as "Running" in the Apify Console, the Standby URL will return a 404, and your MCP client will report a connection error. The Actor logs show a healthy process. Nothing in the logs points to the routing config.
The field appears in one sentence in the Apify Standby documentation. It's easy to miss, easy to typo, and the failure mode is confusing because the Actor looks healthy from the outside.
Fix: webServerMcpPath must match the path argument you pass to FastMCP. If your server mounts at /mcp, the field must be /mcp. Lock them together and verify on first deploy with a curl to the Standby URL.
Gotcha 2: A 406 on /mcp is a PASS, not a failure
When you smoke-test a new Actor with a basic curl:
curl -s -o /dev/null -w "%{http_code}" \
https://unbearable-dev--docker-compose-audit.apify.actor/mcp
You get back 406. First time I saw it I thought the routing was broken. It's not.
406 Not Acceptable means the server received the request, understood the path, and rejected it because the client sent no Accept header specifying an MCP-compatible content type. The server is up. The MCP path is correct. The smoke test passes when you get a 406.
A 404 means the path is wrong (see gotcha 1). A 503 means the Actor isn't running yet. A 406 is the health check you want.
The correct smoke test for a raw Standby endpoint:
# Expect 406 — this is the passing state
curl -s -i https://<actor-standby-url>/mcp | head -1
# HTTP/2 406
# Full MCP session test — expect {"result": {"tools": [...]}}
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
https://<actor-standby-url>/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
The second call is the real smoke test. Empty tools array means your server is up but your tool registrations are not attached. Which brings us to gotcha 3.
Gotcha 3: get_server() scope and the zero-tool server
FastMCP's pattern for Apify Standby looks like this:
from fastmcp import FastMCP
def get_server() -> FastMCP:
server = FastMCP("my-actor")
return server
# Don't do this:
@server.tool()
async def my_tool(content: str) -> str:
...
If you define tools outside the get_server() function using the @server.tool() decorator, they register against a different server instance than the one get_server() returns. The Apify Standby framework calls get_server() to get the server it will serve. Tools registered at module scope against a different instance are unreachable.
tools/list returns an empty array. No error. No log entry. The Actor runs fine.
The fix:
def get_server() -> FastMCP:
server = FastMCP("my-actor")
@server.tool()
async def my_tool(content: str) -> str:
"""Run my tool."""
return do_the_work(content)
return server
Everything that needs to be reachable goes inside get_server(). The returned instance is what gets served.
Gotcha 4: PPE economics post-April 2026
Apify retired rental pricing on April 1, 2026. Pay-per-event is now the only billing model for new Actors. The economics are better than they look.
Every Apify user gets $5/month in free platform credits. At $0.02 per audit call, that's 250 free calls per user per month. The first dollar only flows after a user's free credits are exhausted. Solo developers and hobbyists may never pay — you're pricing for team and enterprise users whose credit buffers get consumed by actual volume.
The pay_per_event.json schema ships in .actor/pay_per_event.json:
{
"light-read": {
"eventTitle": "List / lookup call",
"eventDescription": "Charged for catalog, list, or lookup tool calls.",
"eventPriceUsd": 0.005
},
"heavy-call": {
"eventTitle": "Analysis / audit call",
"eventDescription": "Charged per primary tool call.",
"eventPriceUsd": 0.02
}
}
Two tiers at a 4:1 ratio: cheap discovery, billable execution. Structure your tool catalog so list_checks, list_rules, and schema-query tools fire light-read events; analysis and audit tools fire heavy-call events.
The Actor.charge() call goes before your logic, not after:
async def audit_dockerfile(content: str) -> dict:
await Actor.charge("heavy-call") # charge first
results = run_audit(content) # then do the work
return results
If your logic raises an exception, the charge record still exists. Billing is accurate even on errors. If you charge after the work, a raised exception means an unbilled call — the platform economics break down at volume.
The pay_per_event.json placement gotcha: the file goes in .actor/pay_per_event.json, not the project root. If it's in the wrong location, Actor.charge() executes without raising an error, but no billing event is recorded. No exception, no log entry, nothing on the Apify billing dashboard. The Actor runs correctly. You just aren't billing for it.
The startedAt field in your PPE config must be at least 14 days from your push date. Setting it closer returns a 403 cannot-modify-actor-pricing-with-immediate-effect error. Set it 15–20 days out to be safe.
Gotcha 5: The Dockerfile clobber guard
AI coding agents — Cursor, Claude Code — occasionally write to Dockerfile when they mean to write to a test fixture or a scratch file. apify push uses whatever Dockerfile is in the project root at push time. If the clobber happened before the push, your build uses the wrong base image, exits immediately, and the build logs point at dependency errors rather than the source problem.
The fix: store a SHA-256 hash of Dockerfile in Dockerfile.sha256 and verify it in a pre-commit hook.
Generate the hash:
# On Linux/Mac:
sha256sum Dockerfile | awk '{print $1}' > Dockerfile.sha256
# On Windows (PowerShell):
(Get-FileHash Dockerfile -Algorithm SHA256).Hash.ToLower() | Set-Content Dockerfile.sha256
Pre-commit hook (.git/hooks/pre-commit):
#!/bin/sh
STORED=$(cat Dockerfile.sha256)
ACTUAL=$(sha256sum Dockerfile | awk '{print $1}')
if [ "$STORED" != "$ACTUAL" ]; then
echo "ERROR: Dockerfile has changed since Dockerfile.sha256 was generated."
echo " Stored: $STORED"
echo " Actual: $ACTUAL"
echo "If the change is intentional: sha256sum Dockerfile | awk '{print \$1}' > Dockerfile.sha256"
exit 1
fi
When an agent rewrites Dockerfile accidentally, the hook catches it before the commit lands. Update Dockerfile.sha256 manually when you intentionally change the base image or build steps.
The scaffold at python-mcp-empty-plus ships with Dockerfile.sha256 already set to the correct hash for the default image (apify/actor-python:3.14).
Gotcha 6: Fix-snippet generation — what MCP clients can actually consume
Audit-style Actors return a list of findings. The naive return format is a wall of text or a raw diff. Neither is useful in an agentic workflow.
The pattern that works: a structured response envelope with a machine-readable fixes array.
from typing import TypedDict
class Fix(TypedDict):
rule_id: str
severity: str # "error" | "warning" | "info"
description: str
fix_snippet: str # the corrected content, ready to write
line_range: list[int] # [start, end] line numbers
class AuditResult(TypedDict):
passed: bool
issue_count: int
fixes: list[Fix]
The fix_snippet field carries the corrected content, not a description of what to change. An MCP client driving Claude Desktop can write the fix snippet directly to the file. A diff description requires the agent to derive the edit — an extra round trip and a source of hallucination.
The RULE_MAP pattern separates rule definitions from audit logic:
RULE_MAP = {
"DF-001": {
"title": "Use specific base image tag",
"severity": "error",
"check": lambda layers: not any(":latest" in l for l in layers),
"fix_template": "FROM {image}:{pinned_tag}",
},
# ...
}
Rules as data, not conditionals. Adding a new check is one dict entry. The audit loop iterates RULE_MAP and populates fixes. Clean separation means you can test each rule in isolation without running a full audit.
What the 7 production Actors look like
The Actors live on the Apify Store under unbearable-dev:
-
docker-compose-audit— 25 checks across 9 categories (security, networking, resource limits, image policy, restart policy, secrets management, health checks, logging, compose-file best practices) -
dockerfile-audit— 19 checks across 5 categories (base image, build hygiene, layer efficiency, secrets, user privilege) -
github-actions-audit— 21 checks across 6 categories (action pinning, permission scoping, secret hygiene, event trigger misconfigurations, dependency audit, SLSA provenance) -
k8s-manifest-audit— 63 checks across 7 categories (Deployment, Service, RBAC, NetworkPolicy, PodSecurity, and more) -
iac-audit-pack— the composite: all 4 domains, 131 checks, 43 tools in one Actor -
hu-postcode-validator— 5 utility tools backed by the Hungarian postal code dataset -
szamlazz-mcp— 8 tools wrapping the Számlázz.hu invoicing API, BYO-API-key
Most of them are also wired into a self-hosted HTTP multiplexer on the Pi (unbearable-mcp at port 8700) that exposes them behind a single MCP endpoint. The Smithery catalog lists them individually with a gateway parameter that routes to the right Actor.
The boilerplate I ended up with
After Actor 4, the setup was stable enough to extract. The scaffold (python-mcp-empty-plus) is on GitHub — it's the structure without the explanations:
-
actor.jsonwithwebServerMcpPathalready set -
Dockerfilepinned toapify/actor-python:3.14 -
Dockerfile.sha256for the clobber guard -
.actor/pay_per_event.jsonwith the two-tier template and the PPE gotchas documented inline -
requirements.txtwithfastmcpandapify-client -
src/main.pywith theget_server()pattern and a stub tool -
scripts/smoke.sh— the three-tier smoke test (unit →python -m→ curl/mcp) - Pre-commit hook wired in
The 8-doc pack is the explanations. It covers every pattern in this post in full, with the markdown-linter-mcp example Actor worked through from scaffold to Smithery listing. That Actor is ~200 lines, fully functional, 5 tools, PPE configured. The source ships with the pack.
The docs:
- Quickstart — zero to Standby endpoint in one session
- Check-catalog pattern —
list_checkstool structure and how it shapes PPE billing - Fix-snippet generation — response envelope,
RULE_MAP, why raw diffs break MCP clients - PPE economics post-April 2026 — two-tier design,
Actor.charge()placement, break-even math - Standby URL and cold-start behavior — URL anatomy, Bearer auth, session tracking middleware
- Smoke testing — three-tier harness and what each tier catches
- Dockerfile clobber guard — the SHA-256 approach and the pre-commit hook
- Troubleshooting — 15 failure modes with exact error messages and fixes
If you've read this far and the patterns above saved you time, the pack is €49 at unbearable0.gumroad.com/l/mcp-server-boilerplate-pack. One payment, immediate download, 14-day refund.
If you're not at the "buy a doc pack" stage yet, the free scaffold is at github.com/UnbearableDev/python-mcp-empty-plus. It gets you past the blank-slate problem. The gotchas above are free either way.
Built by Noel @ Unbearable Labs — practical homelab and agent ops. The newsletter is weekly, no filler.
Top comments (0)