Building an MCP Server for Dokku with FastMCP

Building an MCP Server for Dokku with FastMCP
Building an MCP Server for Dokku with FastMCP

You are already in Cursor, mid-debug, when you need to know whether an app is up, what its domains are, or why the last deploy blew up.

The usual path is a context switch: open a terminal, SSH into the Dokku host, run the right plugin commands, paste the output back into chat. By then the thread of the problem has gone cold.

What if you could stay in the conversation instead? Type “is api running, and show me the last fifty log lines” and let the agent hit Dokku for you: same host, same SSH credentials you already trust, without shuttling shell output by hand.

That is what an MCP server is for: tools the model can call while you keep working in the editor.

This post walks through dokku-mcp, a FastMCP server that talks to Dokku over SSH, returns structured results, and ships with guardrails so “convenient” does not become “unrestricted shell access.”


What MCP actually is

The Model Context Protocol is a small contract between an AI client and a side process that owns capabilities the model should not invent on its own: database queries, ticket APIs, or your Dokku host.

In the local setup Cursor and Claude Desktop use most often, the client starts your server as a subprocess and talks to it over stdio (JSON-RPC on stdin/stdout). At connect time the server advertises a catalog of tools: name, description, and a JSON schema for arguments.

When you ask something in chat, the model can choose a tool, the client invokes it on the server, and the result is fed back into the conversation as structured data, not as something you pasted from a terminal.

FastMCP turns Python async functions into that catalog with almost no configuration: decorate or register a function, document side effects in the docstring (clients surface those to the model), and call mcp.run()

The protocol handles discovery and invocation; your job is deciding which operations exist and what they are allowed to do.


Setup: skeleton and SSH

The rest of this post follows the code in dokku-mcp as it ships today. The package lives under src/dokku_mcp/ with a thin layout:

  • server.py — FastMCP instance and tool registration
  • ssh.py — shared asyncssh session
  • config.py — env settings and allow/deny lists
  • parsers.py — CLI stdout → structured data
  • tools/ — one module per Dokku concern

Dependencies are the obvious ones: fastmcp, asyncssh, pydantic-settings, python-dotenv.

One Dokku-specific gotcha matters more than the framework choice. Dokku’s SSH user uses a forced command. You do not run dokku apps:list in a remote shell. You SSH as dokku and send the plugin invocation as the remote command:

ssh dokku@your-host apps:list

The connection layer mirrors that. One shared session per process, a lock so concurrent tool calls don’t interleave, and reconnect if the link drops:

async def run(self, *args: str) -> str:
    """Run a Dokku plugin command over SSH.

    Dokku's forced SSH command expects the plugin invocation as the remote
    command (e.g. ``apps:list``), not ``dokku apps:list``.
    """
    if not args:
        raise ValueError("DokkuSSH.run requires at least one argument")
    command = " ".join(_shell_quote(a) for a in args)
    async with self._lock:
        return await self._run_unlocked(command)

Config comes from the environment (DOKKU_HOST, DOKKU_SSH_USER, DOKKU_SSH_KEY_PATH, …). Defaults favor the Dokku convention: user dokku, port 22, mode read-only.


Tool #1: list_apps

The first useful tool is also the simplest: list apps, prefer JSON, fall back to quiet text, then filter through the allowlist.

async def list_apps() -> list[str]:
    """List Dokku apps on the host.

    Side effects: none (read-only).
    Respects DOKKU_APP_ALLOWLIST / DOKKU_APP_DENYLIST — denied apps are filtered out.
    """
    settings = get_settings()
    try:
        raw = await run_dokku("apps:list", "--format", "json")
    except DokkuSSHError:
        raw = await run_dokku("--quiet", "apps:list")
    apps = parse_apps_list(raw)
    return [app for app in apps if settings.is_app_allowed(app)]

Register it on the FastMCP instance and you’re live:

from fastmcp import FastMCP
from dokku_mcp.tools import apps

mcp = FastMCP("dokku")
mcp.tool(apps.list_apps)

def main() -> None:
    mcp.run()

That is enough for the server process. Wire Cursor (or Claude Desktop) as in the the Example section below, the client config is the same whether you expose one tool or the full set.


Expand the tool set (and face the parsing problem)

Read-only coverage grows the same way: one Dokku command, one tool, one parser.

Tool Dokku command Notes
app_report ps:report <app> Process / running state
app_config config:export <app> Secrets masked by default
app_logs logs <app> -n <n> Default 50 lines, hard max 500
app_domains domains:report <app> VHOST report
app_url derived HTTPS URLs from domains

Dokku isn’t a JSON API. Newer plugins often accept --format json; older output is banner text and Key: value lines. The server prefers JSON when it works, then falls back to text parsers, never a generic “run whatever string the model invents” tool.

That last point is deliberate. An open-ended dokku_exec would be convenient for demos and disastrous in production. Structured tools keep the surface small, the docstrings honest about side effects, and the parsers testable against fixture files of real CLI output.

def parse_apps_list(raw: str) -> list[str]:
    """Parse ``apps:list`` JSON or quiet text output."""
    text = raw.strip()
    if not text:
        return []
    if text.startswith("["):
        data = json.loads(text)
        if isinstance(data, list):
            return [str(item) for item in data]
        raise ValueError("apps:list JSON was not a list")
    apps: list[str] = []
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("=====") or line.lower() == "my apps":
            continue
        apps.append(line)
    return apps

Same pattern for reports, config:export, and logs: small functions, fixture-backed unit tests, no scraping surprises at runtime.


Guardrails: don’t blindly trust AI with infra

Giving a model SSH into your PaaS is only interesting if you assume it will eventually ask for something you don’t want. Guardrails are that protection.

Mode. DOKKU_MCP_MODE defaults to read-only. Mutating tools call require_write_mode() and fail closed unless you set read-write.

Allowlist / denylist. Empty allowlist means all apps; a non-empty list is an explicit permit set. Denylist always wins. Every app-scoped tool checks before SSH.

Secret masking. app_config returns env vars with DATABASE_URL, *_TOKEN, *_PASSWORD, and friends replaced by *** unless the caller passes reveal_secrets=True. The model can reason about which keys exist without leaking credentials into the chat transcript by default.

Confirm for mutations. Even in read-write, restart and scale refuse to run unless confirm=True is passed explicitly, never inferred from chatty assent:

async def restart_app(app: str, confirm: bool = False) -> dict[str, str]:
    """Restart all processes for a Dokku app.

    Side effects: restarts the application (downtime possible).
    Requires DOKKU_MCP_MODE=read-write and confirm=True.
    """
    settings = get_settings()
    settings.require_write_mode()
    settings.require_app_allowed(app)
    if not confirm:
        raise PermissionError(
            "restart_app requires confirm=True to proceed — pass confirm=True explicitly"
        )
    output = await run_dokku("ps:restart", app)
    return {"app": app, "status": "restarted", "output": output.strip()}

set_config follows the same pattern and returns the keys that were set, not the values. create_app validates names (^[a-z0-9-]+$ with alphanumeric edges) and still requires write mode.

What I deliberately left out: destroy_app. Destroying an app is irreversible enough that “the model said yes” is not a controlled safety.

Tool docstrings spell out side effects because MCP clients surface them to the model. That is part of the safety features, not just decoration.


Example: from chat to Dokku

Assume the package is installed (or run from the repo with uv sync). Before wiring Cursor, confirm SSH works the Dokku way, the same forced-command path the server will use:

ssh dokku@your-host apps:list

If that fails, fix the key, host, or Dokku user first; the MCP layer will not rescue a broken SSH setup.

Then add the server to ~/.cursor/mcp.json (or the project .cursor/mcp.json). From the repo with uv:

{
  "mcpServers": {
    "dokku": {
      "command": "uv",
      "args": [
        "run",
        "--directory",
        "~/dokku-mcp-server",
        "dokku-mcp"
      ],
      "env": {
        "DOKKU_HOST": "xxx.xxx.xxx.xxx",
        "DOKKU_SSH_USER": "dokku",
        "DOKKU_SSH_KEY_PATH": "~/.ssh/id_rsa",
        "DOKKU_MCP_MODE": "read-only"
      }
    }
  }
}

Once published, uvx dokku-mcp with the same env block is enough.

Reload MCP in Cursor, then stay in the agent chat.

If dokku does not appear under MCP tools, open the MCP panel and check the server logs: bad host, key path, or a failed uv start show up there before any chat error does.

Dokku MCP Cursor MCP Configuration

Prompts that map cleanly to tools:

You type Tools the model should use
“What apps are on my Dokku host?” list_apps
“Is api running, and what’s its public URL?” app_report, app_url
“Show the last 50 log lines for api.” app_logs
“Which env keys does api have? Don’t show secrets.” app_config (secrets stay masked)

Example of “What apps are on my Dokku host?”:

Example of Dokku MCP in action on a Cursor chat

You never open a terminal for those checks. SSH still happens, but inside the MCP server, on a shared session, with mode and allowlists applied.

When you intentionally enable writes (DOKKU_MCP_MODE=read-write), a prompt like “restart api” only succeeds if the tool call includes confirm=true. If the model omits it, the server refuses. That is the difference between a convenience wrapper and a controlled safety you would actually leave connected.


Conclusion

dokku-mcp is a small, opinionated MCP server: FastMCP on the outside, Dokku over SSH on the inside, dedicated parsers instead of a free-form shell, and defaults that keep day-to-day use read-only.

Issues and PRs are welcome, especially fixture captures from real Dokku versions and extra read-only reports that fit the “one command, one tool, one parser” rule.

If you already run Dokku at home or in a small shop, this is a practical way to keep Dokku in the Cursor chat instead of a separate SSH session.


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice

Follow me on YouTube: https://www.youtube.com/@DevAsService

Nuno Bispo

Nuno Bispo

Solutions Architect · Senior Python & AI Engineer · AI Audits · Helping teams fix what they shipped too fast
Netherlands