All docs

MCP planning tools

MCP planning tools

Module: apps/mcp/src/tools/planning/ Scope guard: plans:write for propose_plan, instantiate_template, approve_plan, and create_project; plans:read for list_projects; tasks:write for add_task and set_dependency.

create_project

Creates the project container every plan/task lives under, so the whole lifecycle (create_projectpropose_plan → approve → add_task) runs from MCP without a detour to the web UI. Returns { projectId, name, slug }.

A project is a container, not a plan — there is no lifecycle column and nothing to approve, so it is "active" the moment it exists (the approval gate lives one level down, on the plan). Name uniqueness is case-insensitive and returns project.name_taken; an explicit colliding slug returns project.slug_taken; an omitted slug is suffixed (-2, -3, …) until unique. The plan-tier project cap (projectsLimit) is enforced via the same gate the UI uses, raising quota.exceeded.

list_projects

Returns { projects: [{ projectId, name, slug, taskCount, status }] } oldest-first (the same project propose_plan defaults to when no projectId is passed, so the default sorts first). status is the constant 'active' — see create_project. Lets an agent discover a real projectId instead of guessing.

propose_plan

SeedTask: { nodeKey, title, description?, executionContract, verificationContract, dependsOn? }. nodeKey is a per-plan stable id; the edge list (dependsOn) uses nodeKey (not UUID) — these are pre-insert.

Always lands the plan as status='proposed' regardless of arg shape — the human approval gate (task 38) flips it to active. Seed tasks are inserted as state='blocked' to prevent the scheduler from picking them up.

Cycle detection: in-memory DFS over the seed graph (the database has no edges yet, so the existing wouldCycle helper doesn't apply). Throws dependency.cycle on detection.

Quota: each seed task counts against tasks_created. Gate runs before the transaction (quota.exceeded won't trigger a tx rollback).

Pub/sub: emits {action:'plan.proposed', planId, at} on pubsub:org:{orgId}:plans.

Project resolution (shared with instantiate_template and the Jira import via resolveProjectId): an explicit projectId is validated against the org; omitted, it defaults to the oldest project. Either failure raises project.not_found carrying a details.recovery hint — { reason, tools: ['create_project','list_projects'], hint } — so an agent can self-serve a project and retry instead of being told to open the UI.

instantiate_template

Thin wrapper over instantiatePlanTemplate (task 22). Inherits all of its guards (cross-org refusal, draft/deprecated rejection, unknown override key → validation.invalid_input, missing key → not_found).

approve_plan

The human approval gate over MCP — the same write the web Approve button does. Flips a proposed plan to active and hands every task to core recomputeReadiness, so only roots (no open hard dependency) flip blocked → ready while the rest stay blocked. Both this tool and the web route call the one shared approvePlan in @drobek/core; there is exactly one way a plan becomes active.

Preconditions: only a proposed plan is approvable. A missing plan (or one in another org) → plan.not_found; an already-active/done/cancelled plan → conflict (the message carries cannot-approve-<status>), which also makes a double-approve a safe no-op rather than a re-trigger.

Returns { planId, status:'active', tasksReadied, readiedTaskIds } so the caller immediately knows how many roots are now claimable via get_next_actionable. Pub/sub: emits {action:'plan.approved', planId, at} on pubsub:org:{orgId}:plans.

add_task

Refuses non-active plans with plan.requires_approval — proposed plans are sealed; humans approve a snapshot, not a moving target. Contracts default to the unblocked-no-verification pair so simple seed tasks need no boilerplate.

set_dependency

Wrapper over addDependency from @drobek/core (task 18). Cycle guards, cross-plan / cross-org refusal, and downstream readiness recompute happen inside that function — the tool only validates input and surfaces the dependency.cycle (and friends) MCP error codes.

Common patterns

  • Tools call assertScope(ctx, …) first, then requireOrgId(ctx). Missing

org → validation.invalid_input (token did not carry an org_id).

  • Audit rows are written from inside @drobek/core (each business function)

or from the propose-plan transaction directly. The MCP layer does not write audit rows itself — that keeps the audit attached to the same tx as the business write.

  • Each handler returns a plain JSON object; the registry adapter encodes it

as { content: [{ type:'text', text: JSON.stringify(...) }] } for MCP.