MCP Tools
This document covers the MissionCache MCP server: the 42 tools that expose MissionCache's task database, MissionCache files, time tracking, and planning surfaces to Claude Code over the Model Context Protocol. It is the layer that makes /missioncache:new, /missioncache:load, and the rest of the slash commands work - the command files are thin wrappers that tell Claude which MCP tools to call in what order, and this doc is the reference for everything those tools do.
It assumes you have read architecture.md for the shared vocabulary (tasks.db, ~/.missioncache/active/<project>/, full_path, heartbeats and sessions, the repo model). If a term in this doc is not defined here, it is defined there.
If you are just trying to use MissionCache from a command or a script, the short version is: every tool lives under the mcp__plugin_missioncache_pm__ prefix in Claude Code (mcp__plugin_missioncache_pm__list_active_tasks, mcp__plugin_missioncache_pm__get_task, etc.), every tool returns a JSON dictionary, and errors come back as {"error": true, "code": "...", "message": "..."} instead of raising. The rest of this doc is for when you want to understand exactly what a tool does, what to pass it, and what to expect back.
What the MCP server is#
MissionCache ships a single MCP server, registered in plugin.json as:
"mcpServers": {
"pm": {
"type": "stdio",
"command": "uvx",
"args": [
"--from", "${CLAUDE_PLUGIN_ROOT}/mcp-server",
"--with", "${CLAUDE_PLUGIN_ROOT}/missioncache-db",
"mcp-missioncache"
]
}
}
The server name is pm (for "project management"), and combined with the plugin name (missioncache), Claude Code exposes every tool under the prefix mcp__plugin_missioncache_pm__<tool_name>. So the Python function list_active_tasks in mcp-server/src/mcp_missioncache/tools_tasks.py is callable from Claude as mcp__plugin_missioncache_pm__list_active_tasks.
The server itself is a FastMCP application. The entry point (server.py) is 31 lines long and does nothing but import the tool modules to trigger their @mcp.tool() decorators:
from .app import mcp # noqa: F401
from . import tools_tasks # noqa: F401
from . import tools_docs # noqa: F401
from . import tools_tracking # noqa: F401
from . import tools_iteration # noqa: F401
from . import tools_planning # noqa: F401
from . import tools_active # noqa: F401
def main():
mcp.run(transport="stdio")
All the logic lives in those six tools_* modules. Every tool function is an async def decorated with @mcp.tool() that takes Annotated[...] parameters with Field(description=...) for MCP schema generation and returns a dict. That return shape is important - tools never raise across the MCP boundary; they catch MissionCacheError and return e.to_dict(), and they catch everything else and return {"error": True, "message": str(e)}. Claude always sees a dict, never an exception.
Why async, why dicts#
The async def signature is required by FastMCP even though MissionCache's tools are pure sync code internally - none of them await anything. The handlers would work identically if they were sync, and the MCP SDK handles them either way; async def is the current FastMCP convention and it costs nothing to follow.
The dict return is also a FastMCP quirk. Tools could return Pydantic models directly, and they do internally (ListTasksResult, TaskDetail, etc. in models.py), but then call .model_dump() before returning. This is the cheapest way to get a stable JSON-serializable shape without depending on FastMCP's schema inference. The models are still useful as internal contracts - you get type checking, field validation, and one place to update when the shape changes.
The seven modules#
| Module | Tools | Purpose |
|---|---|---|
tools_tasks.py |
11 | Task lifecycle: list, get, create, complete, reopen, rename, update fields and notes |
tools_docs.py |
6 | MissionCache files: create, get, update context, get digest, update tasks, get progress |
tools_tracking.py |
7 | Time tracking and repository management |
tools_iteration.py |
3 | Iteration log integration (used by missioncache-auto and the iteration loop) |
tools_planning.py |
7 | Parallel agent execution plans |
tools_active.py |
2 | Active-task pointer for the statusline: set/clear in-progress checklist tasks |
tools_pm.py |
6 | Project management: action items, stakeholders, tickets, project due date |
Total: 42 tools. The rest of this doc walks through them module by module. The style is reference-oriented: each tool gets a brief "when to use this", its parameter list with types and defaults, and what comes back on success. Error behavior is uniform across tools and covered in the error handling section instead of being repeated per tool.
Task lifecycle tools (tools_tasks.py)#
These are the tools you reach for when you are working with tasks as first-class entities in tasks.db. They do not touch MissionCache files directly - that is what the tools_docs module is for.
list_active_tasks#
When to use: You want every active task in the DB, optionally filtered or grouped by repo. This is what /missioncache:load calls first to show the selection table.
Parameters:
repo_path: str | None = None- Filter by repo path. Resolves viadb.get_repo_by_path(), so a canonical absolute path works best.task_type: str | None = None- Filter to"coding"or"non-coding"tasks.include_time: bool = True- Whether to batch-fetch time tracking info. Adds one extra query but the result is shaped for display.prioritize_by_repo: bool = False- Two-tier output mode. WhenTrueandrepo_pathis set, returns repo tasks intasksand non-repo tasks inother_tasks, instead of filtering non-repo tasks out.
Returns: A ListTasksResult with tasks (list of TaskSummary), total_count, filter_applied (human-readable filter description), and optional other_tasks (present only in prioritize-by-repo mode).
Each TaskSummary has: id, name, status, task_type, repo_name, repo_path, jira_key, category, tags, time_total_seconds, time_formatted, last_worked_on, last_worked_ago, has_missioncache_files. The time fields come from a single batch query (db.get_batch_task_times) rather than N individual lookups, which is the reason this tool is faster than calling get_task in a loop.
The last_worked_ago field is computed via db.get_effective_last_updated(task), which takes the max of the DB's updated_at and the mtime of the project's -tasks.md file, so editing the task file from outside MissionCache still advances the "last worked" timestamp.
list_completed_tasks#
When to use: You want recently finished tasks, usually for a dashboard list, a monthly summary, or to find a project to reopen.
Parameters:
days: int = 7- Look-back window in days.db.get_recent_completed()filters oncompleted_at.limit: int = 20- Max tasks to return.
Returns: Same ListTasksResult shape as list_active_tasks. No other_tasks, no prioritization.
Time is not batch-fetched here - each TaskSummary does its own db.get_task_time() call. For small limits (default 20) this is fine; if you set limit very high, be aware you are doing N queries.
get_task#
When to use: You have a task ID or project name and you need everything - progress, time, subtasks, JIRA key, full path, file layout. This is the primary tool for /missioncache:load, and it is also what the dashboard hits when you open a task modal.
Parameters:
task_id: int | None = None- DB primary key.project_name: str | None = None- Alternative totask_id.include_subtasks: bool = True- Pull subtasks viaget_active_tasks_hierarchical.include_updates: bool = True- Pull recent notes (only populated for non-coding tasks).
Provide exactly one of task_id or project_name. Providing both is not an error, but task_id wins.
Returns: A TaskDetail with every TaskSummary field plus full_path, parent_id, branch, pr_url, created_at, updated_at, completed_at, progress (a TaskProgress with completion_pct, total_items, completed_items, remaining_summary), prompt (optimized prompt config, usually null), subtasks (list of TaskSummary), and recent_updates.
The progress field is parsed live from <project>-tasks.md via _parse_task_progress(), so editing the checklist outside MissionCache shows up on the next get_task call. No caching.
find_task_for_directory#
When to use: Claude (or a hook) is running in a directory and needs to know "what MissionCache task, if any, owns this cwd". This is what the activity tracker and session_start hook use to decide whether to record heartbeats.
Parameters:
directory: str- Path to look up. Must exist and must not contain null bytes (validated via_validate_path).session_id: str | None = None- Optional Claude session ID. If provided, the lookup checks the per-session task pointer (~/.claude/hooks/state/projects/<session-id>.json) in addition to cwd-based matching.
Returns: {"found": bool, "task": TaskDetail | None}. When found is False, task is None and there is nothing to do. When True, task is a full TaskDetail with subtasks disabled.
The resolution order is documented in find_task_for_cwd: per-session file (projects/<session-id>.json) first, then cwd directory match under ~/.missioncache/active/. (The legacy pending-project.json priority-1 branch and the now-removed pending-task.json artifact are documented in architecture.md; neither is in the live path.)
create_task#
When to use: You want a new task in the DB and, for coding tasks, a fresh directory under ~/.missioncache/active/<name>/. Slash commands generally call create_missioncache_files instead because it does more in one step, but create_task is the minimal primitive.
Parameters:
name: str- Task name in kebab-case. Validated byproject_files.validate_task_name().task_type: str = "coding"- Must be"coding"or"non-coding". Validated inline.repo_path: str | None = None- Required for coding tasks, ignored for non-coding. Auto-registers the repo viadb.add_repo()if it is not already tracked.jira_key: str | None = None- Optional JIRA ticket ID for display.category: str | None = None- Project category, one of the 13CATEGORIESvalues (bug, feature, refactor, test, docs, infra, ui, api, database, security, perf, coding, noncoding) or an existing custom category (defined in the dashboard Settings). Validated inline; invalid values returnVALIDATION_ERROR.
Returns: CreateTaskResult with task_id, task_name, task_type, category, and missioncache_path (the directory created on disk, or None for non-coding tasks).
Non-coding tasks do not get a directory - they exist only as DB rows and use add_task_update for progress notes. This is the split between "projects with files you edit" and "projects you log notes against" (meetings, reviews, investigations).
complete_task#
When to use: You are done with a task and want it out of the active list. For coding tasks, this also moves the MissionCache files from active/ to completed/.
Parameters:
task_id: int | None = Noneorproject_name: str | None = None- Provide exactly one.move_files: bool = True- Whether to physically move the MissionCache directory tocompleted/. Set toFalseif you want to keep files inactive/while marking the DB status as completed (rare - mostly useful for recovery scenarios).
Returns: CompleteTaskResult with task_id, task_name, previous_status, new_status="completed", completed_at, time_total_formatted. Plus active_children_count (the number of active forks that fork from this project) and, when that is non-zero, a warning string. Completing a parent that still has active forks is allowed - its context file stays readable and shared for them from completed/ - and the warning surfaces so it is not a surprise. The children lookup is advisory: it never fails the completion (active_children_count is None if the lookup itself errors after the completion committed).
If the task is already completed, it raises InvalidStateError with current_state="completed". No-op completions are surfaced rather than silently tolerated.
reopen_task#
When to use: A completed task was marked done prematurely and you need it back in the active list. For coding tasks, moves the MissionCache directory from completed/ back to active/.
Parameters: Same as complete_task - task_id or project_name, plus move_files: bool = True.
Returns: ReopenTaskResult with task_id, task_name, previous_status, new_status="active".
Raises InvalidStateError if the task is not currently completed. This is a strict check - you cannot "reopen" a task that was never completed in the first place.
add_task_update#
When to use: You want to append a timestamped note to a task's update log. Primarily used for non-coding tasks (meeting notes, 1:1 followups, investigation progress) where the file-based <project>-context.md is overkill.
Parameters:
task_id: int- Required. Noproject_namealternative for this one.note: str- The note text. Stored verbatim.
Returns: {"update_id": int, "task_id": int, "task_name": str, "note": str}.
Updates are stored in the task_updates table (one row per call) and surfaced via get_task_updates and via get_task when include_updates=True on non-coding tasks.
get_task_updates#
When to use: You want the history of notes for a task, in reverse-chronological order.
Parameters:
task_id: int- Required.limit: int = 20- Max updates to return.
Returns: {"task_id": int, "task_name": str, "updates": list[dict], "total_count": int}. Each update has id, note, created_at.
rename_task#
When to use: A project needs a new name. This is the safe path: it updates the DB row, moves the MissionCache directory, renames the files inside, and rewrites the template H1 titles - time tracking, heartbeats, sessions, and JIRA links survive because they key on the integer task ID, not the name.
Parameters:
new_name: str- Target name, kebab-case. Trimmed + lowercased before validation.task_id: int | None = Noneorproject_name: str | None = None- Provide one to identify the project.
Returns: RenameTaskResult with name (the canonical stored name - display this, not the typed input), old_name, normalized (whether trim/lowercase changed the input), full_path, files_renamed, h1_rewritten, sessions_updated, and warnings.
Refuses with ALREADY_EXISTS when another project holds the target name (in the DB or on disk), with INVALID_STATE while a missioncache-auto run is in progress, and with VALIDATION_ERROR for legacy nested subtasks (rename the parent instead). Fork children are full projects and rename normally - only nested active/<parent>/<subtask>/ directories are refused.
update_task#
When to use: A task needs its JIRA key or category changed after creation - "link this project to PROJ-123", "this is actually an infra project". This is the conversational equivalent of the CLI's set-jira / set-category.
Parameters:
task_id: int | None = Noneorproject_name: str | None = None- Provide one.jira_key: str | None = None- JIRA ticket ID to set. Omit to leave unchanged; pass the literal string'none'(any case) to clear.category: str | None = None- Category to set, one of the 13CATEGORIESvalues or an existing custom category. Same omit /'none'semantics.
Returns: UpdateTaskResult with task_id, task_name, the stored jira_key and category after the update, and updated (which fields this call changed).
At least one of jira_key / category is required. The category is validated against CATEGORIES before ANY write, so an invalid value never leaves a half-applied update (a valid jira_key in the same call is not written either).
MissionCache file tools (tools_docs.py)#
These tools operate on the -tasks.md, -context.md, -plan.md files under ~/.missioncache/active/<project>/. They are the bridge between the DB (which stores task metadata) and the filesystem (which stores human-readable project state).
create_missioncache_files#
When to use: You are starting a new project and you want the DB row, the directory, and the template files in one call. This is what /missioncache:new runs - it is the MissionCache equivalent of git init for a task.
Parameters:
repo_path: str- Required. Auto-registered if missing.project_name: str- Kebab-case task name.description: str = "TBD"- Short description, embedded into the template files. Max 12 words is the convention but not enforced.jira_key: str | None = None- JIRA ticket ID.branch: str | None = None- Git branch.category: str | None = None- Project category, one of the 13CATEGORIESvalues or an existing custom category. Derived from the project description at creation time by/missioncache:new. Validated before any file is written; set on the task row after the registration scan.tasks: list[str] | None = None- Initial task list to seed<project>-tasks.mdwith. Each string becomes a- [ ]line.plan: dict | None = None- Plan content dict with keys likesummary,goals,approach. Structure is flexible - the template renderer picks up what it finds.fork_of: str | None = None- Parent project name to fork from. Writes a**Fork of:** <parent>line into the new context header; the registration scan reconciles that header into the DB parent link, and the parent's context file becomes this project's shared knowledge layer./missioncache:forksets this. The parent should already exist (active or completed).
Returns: {"success": True, "task_id": int, "task_name": str, "category": str | None, "files": MissionCacheFiles} where MissionCacheFiles has task_dir, plan_file, context_file, tasks_file, prompts_dir. When fork_of was passed, also fork_of, fork_linked (whether the DB link now points at the requested parent - False if the parent did not resolve unambiguously yet), and, when not linked, fork_warning.
Internally this is a four-step dance:
- Ensure the repo is registered (
db.add_repoif new). - Call
project_files.create_missioncache_files()to generate the files from templates (injecting the**Fork of:**header whenfork_ofis set). - Call
db.scan_all_repos()to register the task row in the DB - its reconcile pass also links the**Fork of:**header intoparent_id. - Reconcile the task's
repo_idviadb.find_task_by_full_path/db.update_task_repo- this is the fix for the gotcha wherescan_all_reposcan assign the task to the wrong repo when multiple repos share a prefix.
The reconciliation step is the load-bearing part. Without it, the task would appear under the wrong repo in the dashboard's per-repo view.
get_missioncache_files#
When to use: You need the filesystem paths for a task's MissionCache files. Usually followed by a direct Read/Edit via Claude's standard file tools rather than more MissionCache tool calls.
Parameters:
task_id: int | None = Noneorproject_name: str | None = None- At least one required.
Returns: {"task_id": int | None, "task_name": str, "files": MissionCacheFiles}.
If the task exists in the DB, uses task.full_path to resolve subtask directories correctly (nested under parent). If only project_name is given and the task is not in the DB, falls back to the default active/<name>/ layout.
update_context_file#
When to use: You want to append entries to <project>-context.md without loading the whole file, editing it by hand, and writing it back. This is the main tool for /missioncache:save and is much faster than multiple Read/Edit calls.
Parameters:
context_file: str- Absolute path to the context file. Validated to be underMISSIONCACHE_ROOT(prevents escape via..).next_steps: list[str] | None = None- Lines to add under "Next Steps". Replaces the section if provided.recent_changes: list[str] | None = None- Lines to add under a new "Recent Changes (timestamp)" header.key_decisions: list[str] | None = None- Lines to add under "Key Architectural Decisions".gotchas: list[str] | None = None- Lines to add under "Gotchas".key_files: dict[str, str] | None = None-{path: description}map to add to the "Key Files" table.waiting_on_add: list[dict] | None = None- Waiting-on rows to append, each{"what", "who", "since", "gates"}.sincedefaults to today. Creates the## Waiting onsection immediately before Next Steps when the file predates the convention.waiting_on_resolve: list[dict] | None = None- Rows to resolve, each{"match", "outcome"}. Removes the first row whose What cell containsmatchand records "Resolved (was waiting on): - " in today's Recent Changes subsection.
Returns: {"success": True, "file": str, "timestamp": str, "sections_updated": list[str], "waiting_on_unmatched": list[str], "journal_rolled_over": int}.
All sections are optional. Passing None for a section means "don't touch it". Passing an empty list means "touch the section but add nothing" - surprisingly useful for forcing a timestamp update without changing content. The sections_updated field tells you which sections actually received non-empty input.
waiting_on_unmatched lists match values that resolved to no row - check it, a resolve is never silently dropped (same contract as update_tasks_file.unmatched). journal_rolled_over reports how many Recent Changes subsections the 12-entry cap moved into the per-project <name>-journal.md on this write (0 almost always; informational).
The underlying writer (project_files.update_context_file) updates the "Last Updated" timestamp atomically on every call, regardless of which sections you touched. Cap overflow and the context rewrite happen under one sidecar lock, journal written first (a crash duplicates entries into the journal rather than losing them).
get_context_digest#
When to use: Resuming a project (/missioncache:load Step 2). This is the read path for context files - it replaces reading the whole file, and it is the ONLY way to read a context file past the 256KB Read-tool cap (parsing happens server-side via plain file I/O).
Parameters:
project_name: str- Resolved viaget_missioncache_files(active first, then completed).seen_mtime: float | None = None- For forks: the parent-context mtime this session last saw (from its shared-seen marker). When passed,parent_digest.changed_since_seenreports whether a parallel session updated the shared layer since.
Returns: {"success": True, "file": str, "last_updated": str | None, "hub": str | None, "fork_of": str | None, "related_projects": str | None, "waiting_on": str | None, "next_steps": str | None, "recent_changes_last3": list[str], "section_index": list[{"name", "line"}], "file_size_bytes": int, "health_warnings": list[str], "is_fork": bool, "parent_digest": dict | None, "fork_parent_error": str | None}.
waiting_on and next_steps are the verbatim section bodies. section_index gives every ## heading with its 1-based line number, so a follow-up targeted Read (offset/limit) can fetch one specific section without loading the file. health_warnings carries the same per-project findings as missioncache-db health.
The digest also carries the PM layer (DB-side, best-effort): due_date (the project's target date or None) and action_items_open (open action items as {"id", "label", "what", "requested_by", "assignee", "due_date", "overdue", "source"}), with the PM health warnings merged into health_warnings. When the DB is unavailable these degrade to None/[] - a resume never fails on them.
For a fork (its context header carries **Fork of:** <parent>), is_fork is True and parent_digest describes the parent's shared context: {"name", "context_file", "last_updated", "context_mtime", "changed_since_seen"}. The parent is resolved from active/ then completed/, so a completed parent's shared layer stays reachable. If the parent cannot be read, parent_digest is None and fork_parent_error explains why - is_fork stays True so the caller can still show the fork and prompt a manual re-read rather than silently downgrading to a plain resume. changed_since_seen is True/False when seen_mtime was passed, else None.
update_tasks_file#
When to use: You want to mark tasks as completed, add new tasks, or update the "Remaining" summary line in <project>-tasks.md without editing the file directly.
Parameters:
tasks_file: str- Path validated to be underMISSIONCACHE_ROOT.completed_tasks: list[str] | None = None- Task descriptions to mark as[x]. Matching is substring-based against task lines - pass enough of the title to be unambiguous.new_tasks: list[str] | None = None- New- [ ]lines to append.remaining_summary: str | None = None- The "Remaining:" metadata line summary (max 15 words convention).notes: list[str] | None = None- Notes to append under the "Notes" section.
Returns: {"success": True, ...} plus the progress result from project_files.update_tasks_file (completion percentage, counts, etc.).
get_missioncache_progress#
When to use: You want the completion percentage and counts for a task without the full TaskDetail payload. Cheaper than get_task if you only care about progress.
Parameters:
task_id: int | None = None- Resolves the tasks file path from the DB.tasks_file: str | None = None- Direct path (validated underMISSIONCACHE_ROOT).
Returns: {"task_id": int | None, "file": str, "progress": TaskProgress}.
You can pass either - if both are provided, tasks_file wins. If neither is provided, returns a VALIDATION_ERROR.
Time tracking and repo tools (tools_tracking.py)#
These tools drive the MissionCache heartbeat system and the repository registry. Most of them are called by hooks, not by Claude directly, but they are exposed as MCP tools so commands and scripts can use them too.
record_heartbeat#
When to use: You want to ping the DB to say "I am working on this task now". The MissionCache activity tracker hook calls this on every UserPromptSubmit, but you can also call it manually from a slash command (as /missioncache:load does after loading a project).
Parameters:
task_id: int | None = None- Direct task ID. If provided, records under that task with no lookup.directory: str | None = None- Auto-detect mode. If provided, runsdb.record_heartbeat_auto(directory)which internally callsfind_task_for_cwd.session_id: str | None = None- Claude session ID. Optional, used to key per-session task lookups in auto-detect mode.context: dict | None = None- Arbitrary JSON blob stored with the heartbeat. Currently unused by MissionCache itself but available for custom hooks.
Provide exactly one of task_id or directory.
Returns: HeartbeatResult with heartbeat_id, task_id, task_name. In auto-detect mode when no task is found, returns {"recorded": False, "message": "..."} instead.
Heartbeats are raw timestamps - they don't become time on a task until process_heartbeats aggregates them into sessions.
process_heartbeats#
When to use: You want to flush accumulated heartbeats into sessions rows, which is what the dashboard actually reads for time totals. The MissionCache PreCompact and Stop hooks call this automatically, but you can call it on demand after a batch of work.
Parameters: None.
Returns: ProcessHeartbeatsResult with processed_count (how many heartbeats were aggregated in this call).
The aggregation is idempotent - processed heartbeats are marked and never re-aggregated. See architecture.md's invariants section for the detailed guarantee.
get_task_time#
When to use: You want the total time spent on a task over a period.
Parameters:
task_id: int- Required.period: str = "all"- One of"all","today","week".
Returns: {"task_id": int, "task_name": str, "period": str, "total_seconds": int, "formatted": str, "session_count": int}.
The formatted string is "1h 23m"-style output from db.format_duration. The session_count is per the full period, not per the current day.
list_repos#
When to use: You want to see every tracked repository. Useful for debugging "why is this task not showing up under the right repo".
Parameters:
active_only: bool = True- Filter toactive=1repos (the default DB flag).
Returns: {"repos": list[dict], "total_count": int}. Each repo dict has id, path, short_name, is_active.
add_repo#
When to use: You want to register a repository explicitly. create_task and create_missioncache_files both auto-register on demand, so this tool is mainly for one-off setup.
Parameters:
path: str- Absolute path to the repo root. Validated.short_name: str | None = None- Display name for the dashboard. If omitted, defaults to the last path component.
Returns: {"repo_id": int, "path": str, "short_name": str}.
set_task_repo#
When to use: A task got created against the wrong repository - typically /missioncache:new capturing the wrong working directory - or the project's source of truth moved. /missioncache:load offers this on a repo mismatch.
Parameters:
repo_path: str- The new repository path. Must already be registered (calladd_repofirst if not).task_id: int | None = Noneortask_name: str | None = None- Provide one.resolve_git_root: bool = True- Walk parents ofrepo_pathto the containing git root before rebinding, mirroringcreate_missioncache_files. PassFalsewhen a sub-package inside a monorepo is the real project boundary.
Returns: {"task_id", "task_name", "repo_id", "repo_short_name", "repo_path", "changed"} - plus previous_repo_id when a rebind actually happened. Rebinding to the repo the task already has returns changed: False with a message instead of erroring. A target repo that is not registered returns REPO_NOT_FOUND.
scan_repos#
When to use: You manually created MissionCache files outside of MissionCache tools (or moved them around) and you need the DB to pick them up.
Parameters:
repo_id: int | None = None- Scan only one repo. If omitted, scans all tracked repos.
Returns: {"scanned_count": int, "tasks": list[{"id", "name", "repo_id"}]}.
Scanning walks ~/.missioncache/active/ and ~/.missioncache/completed/ under each repo's MissionCache paths, creates missing DB rows, and updates full_path for existing ones. It does not delete DB rows for tasks whose files are gone - that cleanup is manual.
Iteration log tools (tools_iteration.py)#
These tools are a thin wrapper around iteration_log.py, which writes to <project>-auto-log.md. They exist so MissionCache Auto and the sequential iteration loop can log their progress without having to duplicate file-writing logic. You will rarely call them manually.
log_iteration#
When to use: You completed one iteration of a long-running task and want to record what happened to the auto log.
Parameters:
task_id: int | None = Noneorproject_name: str | None = None- One required.iteration: int = 1- Iteration number.status: str = "SUCCESS"- One of"SUCCESS","FAILED","BLOCKED".task_title: str | None = None- Title of the task being worked on (for the log entry header).what_done: list[str] | None = None- Bullet list of things attempted.files_modified: list[str] | None = None- List of paths touched.validation: dict | None = None- Validation results as{"check_name": "PASS" | "FAIL"}.error_details: str | None = None- Error text for FAILED iterations.next_steps: list[str] | None = None- Suggested next steps for retries.
Returns: {"success": True, "task_name": str, "iteration": int, "status": str, "log_file": str}.
log_iteration_completion#
When to use: The iteration loop finished (either successfully or by timing out) and you want to write the final summary entry.
Parameters:
task_id: int | None = Noneorproject_name: str | None = None- One required.total_iterations: int = 1- How many iterations ran.duration_seconds: int = 0- Total wall clock duration.timed_out: bool = False-Truefor timeout,Falsefor success.
Returns: {"success": True, "task_name": str, "completed": bool, "timed_out": bool, "total_iterations": int, "duration_seconds": int}.
get_iteration_status#
When to use: You want to know where an iteration loop left off without reading the log file by hand.
Parameters:
task_id: int | None = Noneorproject_name: str | None = None- One required.
Returns: {"task_name": str, "iteration_log": {...}, "prompts": {...}}. The iteration_log dict has the parsed state from the auto-log file (iteration count, last status, completed/timed_out flags). The prompts dict has status info from prompts/ if it exists.
Planning tools (tools_planning.py)#
These are the newest set of tools and they serve a different use case than everything above: instead of tracking a single project's progress, they coordinate parallel subagent execution via the Claude Task tool. The workflow is (1) create a plan, (2) register each agent with its dependencies, (3) ask the orchestrator for ready agents, (4) spawn them in parallel, (5) each subagent reports back via update_agent_status, (6) mark the plan complete when done.
This is orthogonal to MissionCache Auto's parallel mode - MissionCache Auto uses multiprocessing and spawns its own Claude CLI subprocesses, whereas these tools let an orchestrating Claude drive a parallel agent swarm via the existing Task tool in a single conversation. They write to the DB (the plans, agent_executions, agent_dependencies tables) and the dashboard can surface them, but the plan tables are considered scoped follow-up territory and some of the DB-layer code for them is still sitting dormant in analytics_db.py (see the missioncache-release context file for the cleanup plan).
If you are not building a parallel-agent orchestrator, you do not need any of these. If you are, they give you the primitives.
create_plan#
Parameters:
name: str- Plan name/description.task_id: int | None = None- Optional association with a MissionCache task.metadata: str | None = None- Optional JSON string. Parsed withjson.loads; invalid JSON returns aVALIDATION_ERROR.
Returns: {"plan_id": int, "name": str, "task_id": int | None, "status": "draft"}.
Plans start in draft state and transition to running as soon as any agent is running.
register_agent_execution#
Parameters:
plan_id: int- Plan to register under.agent_id: str- Short identifier like"01","02". Used for dependency refs.agent_name: str- Human-readable name for display.prompt: str- The task/prompt text this agent will execute.dependencies: list[str] | None = None- List ofagent_ids this agent depends on. Agents with satisfied dependencies become ready to run.max_attempts: int = 3- Retry budget for this agent.
Returns: {"execution_id": int, "plan_id": int, "agent_id": str, "agent_name": str, "dependencies": list, "dependency_records": list[int], "max_attempts": int}.
update_agent_status#
When to use: A subagent finished (or failed) and is reporting back. This is the callback the subagent includes in its own prompt instructions (via spawn_parallel_agents).
Parameters:
plan_id: int,agent_id: str- Identify which agent is updating.status: str- One of"pending","running","completed","failed","blocked".result: str | None = None- Result text for completed agents.error_message: str | None = None- Error text for failed agents.
Returns: {"updated": True, "plan_id": int, "agent_id": str, "status": str}.
After the update, the tool calls _update_plan_counters() to recompute the plan's status based on all its agents. A plan is completed when every agent is in a terminal state (completed, failed, or skipped) with no failures; failed if any agent failed; running if any agent is running.
get_plan_status#
Parameters:
plan_id: int- Required.
Returns: {"plan": dict, "agents": list[dict], "summary": {"total", "pending", "blocked", "running", "completed", "failed"}}.
This is the status snapshot for a plan - the plan metadata, every agent's current state, and a count breakdown by status.
get_ready_agents#
When to use: You are the orchestrator and you want the list of agents that can run next.
Parameters:
plan_id: int- Required.
Returns: {"ready_agents": list[dict], "count": int}. Each ready agent has agent_id, agent_name, prompt, execution_id.
An agent is ready if its status is pending and all its dependencies are completed. Note: failed dependencies do not block. If agent 02 depends on 01 and 01 failed, 02 is still ready to run. This is intentional - the plan can complete partially and you have to deal with the failures yourself if you care about strict cascade blocking.
spawn_parallel_agents#
When to use: You want the actual Task tool invocation arguments for every ready agent in a plan. This is the tool the orchestrator calls when it is about to invoke Task for each agent.
Parameters:
plan_id: int- Required.subagent_type: str = "general-purpose"- TheTasktool'ssubagent_typeto use for every spawned agent. If your plan has heterogeneous agent types, you will need to callget_ready_agentsyourself and build the Task calls by hand.
Returns: {"ready_count": int, "task_calls": list[dict], "instructions": str, "plan_id": int, "plan_name": str}.
Each entry in task_calls has subagent_type, description, prompt, run_in_background: True. The prompt for each agent is wrapped with explicit update_agent_status call instructions so the subagent knows how to report completion. The instructions field is a human-readable reminder that you should dispatch every Task call in a single message to get true parallelism (multiple tool calls in one message run concurrently; multiple messages serialize).
complete_plan#
When to use: Every agent has finished and you want to finalize the plan's completed_at timestamp.
Parameters:
plan_id: int- Required.status: str = "completed"- Must be"completed"or"failed".
Returns: {"plan_id": int, "status": str, "completed": True}.
Most plans will auto-transition via _update_plan_counters, but this tool is the explicit way to finalize a plan regardless of its automatic state.
Active-task pointer tools (tools_active.py)#
These two tools drive the statusline's Task: field: a per-session pointer file recording which checklist items from a project's -tasks.md are currently in progress. The pointer is scoped by session ID so concurrent Claude sessions don't clobber each other's display.
set_active_missioncache_tasks#
When to use: The user signals which checklist task they are working on ("let's do 54a") - /missioncache:load Step 5 calls this so the statusline reflects the focus.
Parameters:
project_name: str- MissionCache project name (kebab-case, validated).task_numbers: list[str]- Checklist numbers to mark in-progress, e.g.['54a']or['56', '57']. Each must match an unchecked[ ]line in the project's tasks.md. An empty list is a no-op clear.session_id: str | None = None- Resolved fromCLAUDE_CODE_SESSION_IDwhen omitted (Claude Code 2.1.154+); pass explicitly for older versions or non-Claude clients.
Returns: {"success": True, "project_name", "task_numbers", "pointer_path"} with the validated set. Replaces any prior pointer for the session (idempotent).
The pointer auto-clears when update_tasks_file marks the pointed-at items [x].
clear_active_missioncache_tasks#
When to use: Focus shifts off-task without completing anything - the statusline field should hide rather than show stale focus.
Parameters:
session_id: str | None = None- Same resolution rules as above.
Returns: {"success": True, "session_id", "cleared": bool} (cleared is False when there was no pointer to remove).
Project management tools (tools_pm.py)#
Thin wrappers over missioncache_db.pm_items - the single write path shared with the dashboard's REST endpoints and the missioncache-db CLI. Every mutation writes SQLite (the source of truth) and re-renders the read-only mirror sections in the project's context file under the sidecar lock, so a change made here is visible in the dashboard immediately and on the next /missioncache:load.
add_action_item#
When to use: A meeting transcript or conversation produced a commitment - the user's or a colleague's. Blocking dependencies belong in Waiting on instead.
Parameters: project_name: str, what: str, requested_by: str | None = None, assignee: str = "me", due_date: str | None = None (YYYY-MM-DD), source: str | None = None (meeting + date, transcript path), notes: str | None = None.
Returns: {"success": True, "item": {...}} - the item carries its stable id (rendered as AI-<id>).
update_action_item#
When to use: Complete (status="done", record the outcome in notes), reopen, drop, reassign, or re-date an item. Only passed fields change; due_date="none" clears the date. completed_at is managed automatically on status transitions.
Parameters: item_id: int, then optional status, what, requested_by, assignee, due_date, notes.
list_action_items#
When to use: Project-scoped with project_name, or omit it for the cross-project scope ("what's due this week, anywhere") - each item then carries its project_name. Filters: status, assignee (case-insensitive; me = the user's own commitments), overdue_only, due_within_days.
Returns: {"success": True, "count": int, "items": [...]} with an overdue flag per item.
set_stakeholder#
When to use: Record who matters to this project and in what role. Upserts on (project, name); remove=True deletes. Renders into ## Stakeholders.
Parameters: project_name: str, name: str, role: str | None = None, notes: str | None = None, remove: bool = False.
set_ticket#
When to use: Link an external ticket. System-agnostic: label + url is the whole contract; system ("jira", "monday", ...) is a display hint never branched on; status is a free-text cache MissionCache never fetches. Upserts on (project, label); remove=True unlinks. When url is omitted, a JIRA-style label gets its URL from the user's prefix map if one matches.
Parameters: project_name: str, label: str, url: str | None = None, system: str | None = None, status: str | None = None, notes: str | None = None, remove: bool = False.
set_project_due_date#
When to use: Committed work got a target date (never fabricate one onto uncommitted backlog - the estimation discipline applies). Renders a **Due:** header line; health flags it within 7 days of the date. Pass "none" (or omit) to clear.
Parameters: project_name: str, due_date: str | None = None (YYYY-MM-DD).
Error handling#
Every tool in the server follows the same error-handling pattern, which is worth memorizing because it is the contract for the entire MCP surface.
Success shape#
On success, a tool returns a JSON dict. Keys vary by tool, but there is no top-level "success": true marker on every response - some tools include it for convenience (create_missioncache_files, update_context_file, log_iteration), others don't (list_active_tasks, get_task). The presence of an "error" key is the reliable way to tell success from failure.
Error shape#
On error, a tool returns:
{
"error": true,
"code": "TASK_NOT_FOUND",
"message": "Task not found: my-project",
"details": {"task_id": "my-project"}
}
The code is one of the values defined in errors.py:ErrorCode:
| Code | Meaning |
|---|---|
TASK_NOT_FOUND |
Task ID or name does not resolve |
REPO_NOT_FOUND |
Repository path is not registered |
FILE_NOT_FOUND |
MissionCache file (or other file) does not exist |
VALIDATION_ERROR |
Input parameter failed validation |
DB_ERROR |
SQLite operation failed |
PERMISSION_ERROR |
Filesystem permission denied |
INVALID_STATE |
Operation not allowed in current state (e.g., reopening a task that isn't completed) |
OPERATION_FAILED |
Catch-all for operation-specific failures |
The details field is tool-specific and may contain things like task_id, current_state, expected_state, field (for validation errors), or path (for file errors).
Unexpected errors#
If a tool hits an exception that is not an MissionCacheError subclass, it logs via logger.exception(...) and returns:
{
"error": true,
"message": "<exception str>"
}
No code field, no details. This is the "something unexpected broke" path and should show up in the MCP server's stderr log (uvx pipes logs through stdio; check wherever your Claude Code logs MCP server stderr). If you see an error response without a code, treat it as a bug and go read the server logs.
Why no raises#
Tools do not raise across the MCP boundary because FastMCP would serialize the exception as a protocol error, and Claude would see a generic MCP failure instead of the structured MissionCache error. Returning a dict lets Claude reason about what went wrong (task not found vs validation error vs DB crash) and react appropriately from a slash command.
The tradeoff is that tool callers must check "error" in result after every call. A helper wrapper could raise on error dicts, but none exists today - the MCP contract is "dicts in, dicts out" and every call site handles the check inline.
Shared patterns#
These are patterns that show up in multiple tools. If you are writing a new one, following them keeps the surface consistent.
task_id vs project_name#
Many tools accept either a task_id: int or a project_name: str. The convention is:
- Provide exactly one of them.
- If neither is provided, return a
VALIDATION_ERROR. - If both are provided,
task_idwins andproject_nameis ignored. (This is not a validation error, so be careful - passing both does not warn you.) - Resolve via
db.get_task(task_id)ordb.get_task_by_name(project_name), then check forNoneand raiseTaskNotFoundErrorif the lookup failed.
Tools that only accept task_id (add_task_update, get_task_time, get_task_updates) are the exception - they exist because the parameter is almost always known at call time.
Path validation#
Any tool that takes a filesystem path runs it through helpers._validate_path(path, field_name, must_be_under=...). This:
- Rejects empty strings.
- Rejects null bytes (common injection vector).
- Resolves the path (
Path(path).resolve()). - If
must_be_underis set, verifies the resolved path is inside that directory.
The must_be_under check is what prevents update_context_file from writing to arbitrary files - it is always called with must_be_under=settings.root, so a path like /tmp/evil.md or ~/.missioncache/../evil.md gets rejected.
Tools that should not restrict paths (e.g., record_heartbeat with a directory arg pointing at a repo root) omit must_be_under and just get null-byte and empty-string checks.
Time filters#
Time-related tools that accept a period parameter (get_task_time, some batch helpers) all use the same vocabulary:
"all"- all time, unbounded."today"- from midnight local time to now."week"- from 7 days ago to now (rolling, not calendar week).
Pass anything else and the underlying DB method either returns "all" implicitly or raises, depending on which method you hit. Stick to the three documented values.
Single-shot reads vs batch reads#
Two patterns show up when fetching time:
- Per-task lookup -
db.get_task_time(task_id, period). One SQL query per call. Fine for individual tasks. - Batch lookup -
db.get_batch_task_times(task_ids). One SQL query for N tasks. Required for list views that would otherwise do N queries.
list_active_tasks uses the batch form when include_time=True to avoid the N+1 problem. list_completed_tasks does not, because its limit is small by default. If you are writing a new tool that returns a list with time info, prefer the batch form.
Adding a new tool#
If you have a new operation that belongs in the MCP server, the pattern is:
- Pick the right module. Task lifecycle goes in
tools_tasks.py, file ops intools_docs.py, time/repo intools_tracking.py, iteration log intools_iteration.py, planning intools_planning.py, active-task pointer intools_active.py. If it does not fit any of these, consider whether it belongs in the server at all before creating a new module. - Write the signature.
@mcp.tool() async def my_tool( param1: Annotated[str, Field(description="Clear description")], param2: Annotated[int | None, Field(description="Optional param")] = None, ) -> dict: """Short description shown in MCP help.""" db = get_db() try: # ... do work ... return {"success": True, ...} except MissionCacheError as e: return e.to_dict() except Exception as e: logger.exception("Error in my_tool") return {"error": True, "message": str(e)} - Use typed returns where it helps. If your tool returns a fixed shape, add a Pydantic model in
models.pyand.model_dump()it at the return site. If the shape is more ad-hoc, return a plain dict. - Validate inputs early. Call
_validate_pathfor paths, returnVALIDATION_ERRORdicts for bad enum values, raiseMissionCacheErrorsubclasses for expected failures. - No new imports in
server.py. The tool is registered automatically when its module is imported -server.pyimportstools_<module>once, and that triggers every@mcp.tool()in the file. Do not reach intoserver.py. - Reload the plugin to pick it up.
claude plugins install missioncache@localand restart Claude Code. The MCP server restart happens on the next Claude session. - Add a test.
mcp-server/tests/has fixtures for DB setup and patterns for calling tools directly (bypassing MCP transport). Follow them.
The thing that makes this server pleasant to extend is that tools are flat, independent functions with no shared state beyond the DB instance. There is no plugin registry, no base class, no middleware. Adding a tool is additive - you cannot accidentally break an existing one.
Troubleshooting#
"The tool is missing from Claude's tool list"#
Cause: The plugin cache is stale. MCP tools are discovered at plugin load, and a new tool added after you installed the plugin won't show up until you reinstall.
Fix: claude plugins install missioncache@local (or missioncache@missioncache if you are on the marketplace path) and restart your Claude Code session. /reload-plugins does not cover MCP servers.
"I'm calling the tool but getting error: true, message: <python exception> with no code"#
Cause: An unhandled exception hit the except Exception fallback. This is not an MissionCacheError - it is something unexpected.
Fix: Check the MCP server stderr. uvx pipes stderr through stdio to Claude Code, which logs it to its own files. The server uses logger.exception which includes a full traceback. Once you have the traceback, treat it as a normal Python bug: find the line, fix the root cause.
"Tool returns FILE_NOT_FOUND but the file exists"#
Cause: Path validation is resolving to a different place than you expected. _validate_path calls Path.resolve(), which follows symlinks and normalizes ... If the resolved path falls outside MISSIONCACHE_ROOT, you get VALIDATION_ERROR - if it resolves inside but the file is not there, you get FILE_NOT_FOUND.
Fix: Print the path you are passing and run it through Path(path).resolve() manually in a Python REPL. Compare against the expected ~/.missioncache/active/<name>/... shape.
"Task shows up under the wrong repo after create_missioncache_files"#
Cause: This is the bug that motivated the find_task_by_full_path / update_task_repo reconciliation in create_missioncache_files. scan_all_repos() matches tasks to repos by prefix, and when two repos share a prefix, it can pick the wrong one.
Fix: Already fixed in current code. If you see it anyway, make sure you are on a recent MissionCache version and that create_missioncache_files is the tool you are calling (not a manual create_task + scan_all_repos sequence, which does not include the reconciliation step).
"list_active_tasks is slow for big project counts"#
Cause: include_time=True is the default, and even with batch lookups, 100+ tasks means 100+ rows joined across heartbeats, sessions, and tasks.
Fix: Pass include_time=False if you are not displaying time. The tool returns in under a millisecond without time, versus tens of milliseconds with time. For the dashboard, which always wants time, the batch query is fine; for scripts that just want the task list, skip it.
"Heartbeats aren't showing up as time"#
Cause: record_heartbeat writes to heartbeats (unprocessed) but get_task_time reads from sessions (processed). The aggregation step in between only happens when process_heartbeats runs.
Fix: Call process_heartbeats manually, or wait for the next PreCompact / Stop hook. See architecture.md's invariants for the full guarantee.
"Plan agents report status but plan status doesn't update"#
Cause: _update_plan_counters only runs inside update_agent_status. If you are mutating the DB directly (e.g., via sqlite3 or through missioncache_db internals), the plan counter sync is skipped.
Fix: Always go through update_agent_status for status updates. If you need to bulk-update a plan, consider making a new MCP tool that takes a list of (agent_id, status) pairs and loops through update_agent_status - don't bypass it.
Where to go from here#
architecture.md- if you need the big picture ontasks.db, the hook model, or whatfull_pathmeans.dashboard.md- if you want to see how the tools' return shapes get rendered in the UI.missioncache-auto.md- if you are specifically curious about howlog_iteration/get_iteration_statusfit into an autonomous run.mcp-server/src/mcp_missioncache/tools_*.py- the source. Each file is flat and independent; if you know which module owns a tool from the prefix, you can jump straight there.mcp-server/src/mcp_missioncache/models.py- the full Pydantic models for every typed return. The canonical source for "what fields does this tool give me".mcp-server/src/mcp_missioncache/errors.py- the full error code enum and exception types. Useful when writing a new tool or adding new error cases.