Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP Plan

MCP server that provides planning tooling.

Installation

mcp-plan is packaged as a Nix flake. Run it directly without installing:

nix run github:haras-unicorn/mcp-plan -- run

or build the mcp-plan binary with:

nix build github:haras-unicorn/mcp-plan

Releases

Prebuilt binaries for x86_64-linux and aarch64-linux, for each supported database backend (sqlite, postgres, mysql or all), are attached to each GitHub release as tarballs containing the mcp-plan binary.

To run with a specific backend (in this example sqlite) from releases:

curl -L -o mcp-plan.tar.gz \
  https://github.com/haras-unicorn/mcp-plan/releases/latest/download/mcp-plan-x86_64-linux-sqlite.tar.gz
tar -xzf mcp-plan.tar.gz
./mcp-plan-x86_64-linux-sqlite run

To run with a binary supporting all backends from releases:

curl -L -o mcp-plan.tar.gz \
  https://github.com/haras-unicorn/mcp-plan/releases/latest/download/mcp-plan-x86_64-linux.tar.gz
tar -xzf mcp-plan.tar.gz
./mcp-plan-x86_64-linux run

Pick the archive matching your backend:

  • mcp-plan-x86_64-linux.tar.gz (all backends)
  • mcp-plan-x86_64-linux-sqlite.tar.gz
  • mcp-plan-x86_64-linux-postgres.tar.gz
  • mcp-plan-x86_64-linux-mysql.tar.gz
  • mcp-plan-aarch64-linux.tar.gz (all backends)
  • mcp-plan-aarch64-linux-{sqlite,postgres,mysql}.tar.gz

NixOS and home-manager

Add the flake as an input and apply its overlay so that mcp-plan is available in your system configuration:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    mcp-plan.url = "github:haras-unicorn/mcp-plan";
  };

  outputs =
    { nixpkgs, mcp-plan, ... }:
    {
      nixosConfigurations.my-machine = nixpkgs.lib.nixosSystem {
        modules = [
          { nixpkgs.overlays = [ mcp-plan.overlays.default ]; }
        ];
      };
    };
}

Then add pkgs.mcp-plan to your packages, either in NixOS:

{ pkgs, ... }:
{
  environment.systemPackages = [ pkgs.mcp-plan ];
}

or with home-manager:

{ pkgs, ... }:
{
  home.packages = [ pkgs.mcp-plan ];
}

Binary cache

Builds are cached on the haras cachix cache. When the flake is used directly (for example with nix run github:haras-unicorn/mcp-plan), the cache is configured automatically through the flake’s nixConfig. To use it when the package comes from an overlay, add the following to your nix configuration:

{
  nix.settings = {
    substituters = [ "https://haras.cachix.org" ];
    trusted-public-keys = [
      "haras.cachix.org-1:/HIo1JYqOIH1Nwk1EGXhuPPvDW0WekxIbY5CiXUZbYw="
    ];
  };
}

Usage

mcp-plan is an MCP server that speaks the Model Context Protocol over stdio. Add it as a stdio MCP server to any MCP client, for example:

{
  "mcpServers": {
    "mcp-plan": {
      "command": "nix",
      "args": ["run", "github:haras-unicorn/mcp-plan", "--", "run"]
    }
  }
}

If mcp-plan is already on your PATH, point the client at the binary directly instead:

{
  "mcpServers": {
    "mcp-plan": {
      "command": "mcp-plan",
      "args": ["run"]
    }
  }
}

Configuration

mcp-plan reads its configuration from config.toml in the working directory, overlaid with MCP_PLAN_* environment variables. Every setting is optional. The full schema and a worked example live in the References section.

Command line

The binary accepts a subcommand:

  • mcp-plan run — start the MCP server over stdio (default).
  • mcp-plan migrate — open the database and apply pending migrations, then exit.
  • mcp-plan schema — write the configuration JSON schema to --output.

--config <path> is a global flag selecting a different configuration file (defaults to config.toml), for example:

mcp-plan --config ./prod.toml migrate run

Configuration file

[database]
url = "sqlite://data/mcp-plan.db"

The file is split into three sections:

  • database — the database url. See below for the supported schemes.
  • runtime — tps_in, tps_out, max_task_duration_secs, queue_limit, max_retries.
  • sources — a list of statically configured sources.

See the JSON schema and the example for the exact keys and defaults (References below).

Environment

Environment variables override file values. Use the MCP_PLAN prefix with __ as the section separator:

MCP_PLAN__RUNTIME__TPS_IN=1000 mcp-plan run

Logging

Logs are emitted as newline-delimited JSON on stderr, keeping stdout exclusively for the MCP JSON-RPC protocol. Each line carries a level, timestamp, target, a human-readable message, and structured fields (e.g. task_id, duration_ms) that are safe to query with jq:

RUST_LOG=debug mcp-plan run 2> >(jq -r '"\(.level): \(.message)"')

Log verbosity is controlled via RUST_LOG (default info). Any standard tracing filter is accepted (error, warn, info, debug, trace, per-target filters, etc.).

Database

database.url selects the backend by scheme:

  • sqlite://data/mcp-plan.db — a SQLite file (relative to the working directory). The database file and its parent directory are created on first start. Use an absolute path (e.g. sqlite:///var/lib/mcp-plan.db) or sqlite://:memory: for an in-memory database.
  • postgres://user:password@host:port/database — PostgreSQL.
  • mysql://user:password@host:port/database — MySQL.

Each binary is built against a single backend (sqlite by default, or the postgres/mysql build variants from the releases).

Both run and migrate connect to the database and apply pending migrations at startup; migrate exits immediately afterwards so migrations can be run as a separate init step (e.g. in multi-tenant deployments). SQLite runs in WAL mode with foreign keys enabled.

Integration

mcp-plan is a regular MCP server, so any MCP client — including a custom agent runtime — can drive it. Register it as a stdio server (see the Usage example) and expose the plan__* tools to your agent.

To keep an autonomous agent working over time, add a scheduler of your choice (a cron entry, a CI scheduled job, a loop inside an existing daemon or an OpenClaw heartbeat/agent cron job) that periodically connects to mcp-plan and instructs the agent to run a planning/delegation pass. An example heartbeat prompt is provided in References section—you can point a cron job at it verbatim or use it as a template:

# example: run an agent-driven planning pass on an interval
*/30 * * * * mcp-plan-with-agent --instruct assets/heartbeat.md

Exact wiring depends on your runtime; the example shows the shape. The tasks and sources live in the database, so each pass should continue where the previous one stopped.

References

Configuration schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Config",
  "description": "Runtime configuration for a single `mcp-plan` server.",
  "type": "object",
  "properties": {
    "database": {
      "$ref": "#/$defs/DatabaseConfig"
    },
    "runtime": {
      "$ref": "#/$defs/RuntimeConfig"
    },
    "sources": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/SourceConfig"
      }
    }
  },
  "$defs": {
    "DatabaseConfig": {
      "description": "Database configuration.",
      "type": "object",
      "properties": {
        "url": {
          "description": "Database URL. Accepts `sqlite://data/mcp-plan.db`, `sqlite://:memory:`,\n`postgres://user:password@host:port/database` or\n`mysql://user:password@host:port/database`.",
          "type": "string",
          "default": "sqlite://data/mcp-plan.db"
        }
      }
    },
    "RuntimeConfig": {
      "description": "Estimated throughput and queueing knobs used by the MCP tools.",
      "type": "object",
      "properties": {
        "max_retries": {
          "description": "A task is escalated once its retry count reaches this value.",
          "type": "integer",
          "format": "uint32",
          "default": 3,
          "minimum": 0
        },
        "max_task_duration_secs": {
          "description": "Estimated task duration above which a task is flagged for planning.",
          "type": "integer",
          "format": "uint64",
          "default": 600,
          "minimum": 0
        },
        "queue_limit": {
          "description": "Upper bound for the list returned by `queue()`.",
          "type": "integer",
          "format": "uint",
          "default": 20,
          "minimum": 0
        },
        "tps_in": {
          "description": "Estimated input throughput of the model, in tokens per second.",
          "type": "integer",
          "format": "uint64",
          "default": 800,
          "minimum": 0
        },
        "tps_out": {
          "description": "Estimated output throughput of the model, in tokens per second.",
          "type": "integer",
          "format": "uint64",
          "default": 28,
          "minimum": 0
        }
      }
    },
    "SourceConfig": {
      "description": "A statically configured source, synced into the `sources` table.",
      "type": "object",
      "properties": {
        "description": {
          "type": "string",
          "default": ""
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "type": {
          "$ref": "#/$defs/SourceType"
        }
      },
      "required": ["id", "title"]
    },
    "SourceType": {
      "description": "How a source is fed into the task graph.",
      "type": "string",
      "enum": ["manual", "poll"]
    }
  }
}

Configuration example

# mcp-plan configuration.
#
# Copy this file to `config.toml` (the default location) and adjust as needed.
# Every key is optional; the values below show the defaults.
#
# Environment overrides use the `MCP_PLAN` prefix with `__` as the section
# separator, e.g. `MCP_PLAN__RUNTIME__TPS_IN=1000`.

[database]
# Database URL. Any of the supported backends:
#   sqlite://data/mcp-plan.db          (relative file)
#   sqlite:///var/lib/mcp-plan/mcp-plan.db   (absolute file)
#   sqlite://:memory:
#   postgres://user:password@host:5432/database
#   mysql://user:password@host:3306/database
url = "sqlite://data/mcp-plan.db"

[runtime]
# Estimated model throughput, tokens per second.
tps_in = 800
tps_out = 28
# If the estimated duration of a task exceeds this it is flagged for planning.
max_task_duration_secs = 600
# Upper bound for the list returned by `queue()`.
queue_limit = 20
# A task is escalated once its retry count reaches this value.
max_retries = 3

# Sources are configured statically and synced into the `sources` table.
# `type` is either "manual" or "poll".
[[sources]]
id = "gh-issues"
title = "GitHub issues"
description = "Fetch open issues and insert them as tasks."
type = "poll"

Heartbeat

# HEARTBEAT.md

This is a description of your heartbeat. Your heartbeat is a persistent cron job
that fires off every once in a while on a user-defined interval. You will use
the `plan__*` tools to create task trees for yourself and then execute those
tasks in a structured manner to avoid recreating a workflow for yourself on your
own on every heartbeat. The `plan__*` tools expose a way for you to manage task
trees in a structured manner. The task trees are stored as a self-referential
table in the database that the planning MCP server manages.

## Instructions

1. Call `plan__sources` to receive a list of task sources and instructions on
   how to create new tasks from those sources. After fetching sources you should
   create a list of tasks that could be added to your task tree as prose.

2. After creating tasks as prose you should use the `plan__task`,
   `plan__children` and `plan__insert` tools to find fitting locations to put
   new tasks in and put them there. It is always important to check if a
   particular task already exists to not duplicate it before insertion. When a
   source describes a task as coming from a particular external item (an issue,
   a PR, a discussion, etc.) you should pass the canonical URL or reference for
   that item as `object.link` when inserting the task. `link` is unique when
   set, so you can use it to deduplicate: before inserting a task, call
   `plan__task` with only `link` to see whether a task for that item already
   exists, and skip the insert if it does. If an insert is rejected because
   another task already holds that `link`, treat it as a duplicate and ignore it
   rather than retrying. Keep in mind that inserting is a sort of tree traversal
   to insert new tasks into the already present or new task trees.

3. After reading task sources and carefully inserting new tasks into task trees
   you need to call `plan__queue` which creates a list of tasks which are most
   important from all the task trees and hands them off to you for execution or
   planning. The following points explain how to handle both kinds of tasks.
   - Execution: You should delegate execution tasks to the appropriate `writer`,
     `junior_dev` or `senior_dev` agents. If they fail and the configured
     maximum amount of retries is reached (currently 3) then you have to
     escalate the task to the user and mark the task as escalated via
     `plan__escalate`. Otherwise, if they fail you have to mark the task as
     failed with the `plan__fail` tool. If they succeed you have to mark the
     task with the `plan__complete` tool. For each executed task no matter the
     end result of the execution you have to call exactly one of the three
     `plan__escalate`, `plan__fail`, and `plan__complete` tools. Sometimes
     execution tasks can actually be research or prototyping tasks in which case
     the result of the task execution, if successful, should result in you
     either escalating to the user or traversing the plan trees via `plan__task`
     or `plan__children` and calling `plan__insert` to create new tasks after
     the research or prototyping has been done.
   - Planning: You should break down these tasks yourself into smaller chunks
     via the `plan__insert` tool by creating one level deep children tasks. If
     some or all of the children also need planning, they will be planned out in
     a future heartbeat to avoid infinite recursion of task planning during a
     single heartbeat. During planning, you may also use the `plan__task` and
     `plan__children` tools to inspect how the task that is being currently
     planned relates to other tasks. It is important to note that you may be
     required sometimes to create research or prototyping tasks that will result
     in those tasks getting executed to create new tasks. You should never do
     actual research or prototyping or any sort of work when planning and
     instead you should create tasks that you can execute to create new tasks
     after the research or prototyping tasks have been executed. After planning
     out each task you have to mark the task you just planned out as completed
     via `plan__complete`.

4. After all the tasks that were queued were handled as instructed, you have to
   write a report on what happened during the heartbeat. When creating the
   report you should never include full tasks in the report and always summarize
   tasks and processes. The report should contain the following:
   - A list of newly added tasks during the task sourcing stage (1. and 2.).
   - A list of tasks that were queued (in 3.), their original state and their
     new state along with methods used and potentially new tasks that were
     created as a result of tasks that were more about research or prototyping.

## Notes

- Task descriptions should be written in markdown and contain the following:
  - A task header with the title of the task and a short description of the task
    in one paragraph.
  - A "what" subheader that goes over exactly what is expected to be done.
  - A "why" subheader that goes over why the task was created in the first place
    that should also have some info on the entire chain of tasks that go from
    the task tree root to the task at hand. Just remember to not leave
    "references" to other tasks and rather mention them in prose because the
    delegates don't have access to tasks like you do.
  - A "acceptance criteria" subheader that goes over specifically what needs to
    be checked and how it needs to be checked in order to verify that the task
    is successfully completed. These instructions will be executed by the
    delegate and you should instruct the delegate to give you a report on
    exactly what criteria succeeded or failed in prose.
  - An optional "how" subheader if there is a specific requirement for the task
    on how it should be achieved. Write this only when the user asks for it or
    when you have already done research or prototyping on what strategy a
    delegate should take in order to complete the task.
- A task that is dedicated to research or prototyping may also have sub tasks
  because sometimes research and prototyping can take more time than expected.
- Your delegate agents are not allowed to do planning for you. You are the sole
  planner of everything and you should always use the delegate agent reports to
  plan.
- After a task has been escalated the user will step in and either modify and
  ready the task with the `plan__ready` tool with the chat agent or they will
  execute the task themselves and mark the task as complete with
  `plan__complete` via the chat agent.
- `plan__insert` always creates a task with the `ready` status and it is
  expected that it will be queued on future heartbeats. Planning and execution
  are task kinds and you have no control over those.

## Rules

- You are only ever allowed to estimate tokens and never to estimate what task
  is for planning and what task is for execution. This decision is left to the
  MCP server based on the configured max amount of time of an execution task and
  the token speeds of the configured task execution models.
- You are never allowed to go more than one-level deep when planning out tasks.
- You are not allowed to insert tasks in different subtrees other than the
  subtree of the task you are currently planning. You may, after executing
  research or prototyping tasks, insert tasks in different subtrees other than
  the subtree of the task that was currently researched or prototyped.
- Always plan and execute tasks in the order the `plan__queue` tool gave you the
  tasks.
- If nothing needs work you should escalate to the user, make a one-line report
  and exit cleanly.