task-queue-mcp
Sends notifications via Matrix when tasks are queued in semi-auto mode, allowing operators to pick up tasks.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@task-queue-mcpSubmit a build task for deployment."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
task-queue-mcp
A FastMCP server that exposes the agent orchestration task queue as an MCP tool interface. Agents submit tasks, check status, and record completions through typed, validated tools instead of raw YAML file writes.
Runs as a Docker container on port 8485. Wired globally into ~/.claude.json so all Claude Code agent sessions have access.
Tools
Tool | Description |
| Create a new task with |
| List tasks with optional filters; TTL-expired tasks excluded |
| Retrieve a single task by UUID (resolves archived tasks too) |
| Agent-facing status transition (strict); appends a history entry |
| Operator status change — approve, cancel, park, or advance a missed task (audited override) |
| Graceful terminal |
| Pause a task without hiding it — stays listed, exempt from TTL, nothing picks it up |
| Return a parked task to the status it was parked from |
| Append a correction to a queued task; the original description is never rewritten |
Agents use the strict update_task path; operators (via the HTTP control API) use
set_task_status / cancel_task / park_task / unpark_task. Agents cannot cancel or
park — both are operator-only. amend_task is the exception: the task's source agent may
amend it, but the target agent may not.
submit_task
submit_task(
source_agent="research",
target_agent="deploy-agent", # agent name or "auto" for dispatcher routing
# build | deploy | fix | research | review | audit | notify | docs |
# ticket_audit | ticket_audit_complete
task_type="build",
summary="Deploy qmd update",
description="Apply the qmd stack update from build plan...",
risk_level="low", # low | medium | high (default: low)
requires_approval=False, # explicit override of approval gate
priority="normal", # normal | high | urgent (default: normal)
context_refs=["/srv/agents/build-plans/qmd/plan.md"], # absolute paths only
ttl_days=30,
workflow_mode="semi-auto", # semi-auto | auto (default: semi-auto)
originating_task_id=None, # UUID of the parent task, if this is a return task
)
# → {"ok": true, "task_id": "<uuid>", "filename": "<timestamp>-<slug>.yml"}context_refs must be absolute paths. risk_level and priority are validated against allowlists. workflow_mode controls dispatcher behavior: semi-auto (default) queues the task for operator pickup with a Matrix notification, while auto triggers the dispatcher to launch the target agent headlessly. The server generates the UUID, sets created, and initializes the retry_policy stub.
Auto-close of the originating task (since v0.6.0)
Pass originating_task_id and the parent is closed as completed — submitting the return task is what closes the request. The response gains auto_closed_task_id when it fires.
It fires only if all of these hold:
Condition | Why |
the parent resolves, and is not archived | nothing to close otherwise |
| the bound on the whole feature — agent A must not be able to close agent B's task by naming it as a parent. Checked here explicitly rather than relying on |
| the other half of the return shape — you must be answering whoever asked. Without it a forward request looks identical to a return (see below) |
parent is at |
|
Why both halves (since v0.6.1). originating_task_id is overloaded: on a return task it means "this answers that request", but on a forward request it means "inherit workflow_mode from this parent" — which is what a build agent passes when it files an audit request for its own in-flight build. Checking only the first condition cannot tell those apart, because the build task targets the build agent and the build agent is the submitter. v0.6.0 shipped with only the first check and closed a live in-flight build task within the hour.
A genuine return is symmetric; a forward request is not:
parent | new task | fires? | |
return | audit |
| yes — both halves hold |
forward request | build | audit request | no — |
An approved parent is walked through in-progress first, so its history reads as claimed-then-closed rather than teleported.
This is a fail-safe, not the primary path. Agents are still expected to close their own tasks explicitly — that puts the agent's own note in the history, where this writes only auto-closed: return task <id> submitted. Any failure inside the auto-close is logged at warning level and the submit returns normally; it can never fail the submit it is a side effect of.
list_tasks
list_tasks(
target_agent="deploy-agent", # optional
source_agent="research", # optional
status="approved,in-progress", # comma-separated, optional
task_type="build", # optional
include_archived=False, # include archive/ subdirectory
limit=20, # max 200
)
# → list of task dicts, sorted by created descendingAn unrecognised status is an error, not an empty result (since v0.6.0). It used to be filtered on silently, which is how a sweep for status="pending" — never a status here — returned [] for months, indistinguishable from "no work for you". An empty list is a legitimate answer to a well-formed question, so the only way to tell a typo apart from an empty queue is to refuse the typo. Whitespace and a trailing comma are still tolerated; an empty string still means no filter.
Terminal tasks past their ttl_days are excluded. The dispatcher is authoritative for TTL archiving, but list_tasks filters finished records out proactively so agents don't act on stale items.
Non-terminal tasks are never TTL-filtered (since v0.8.1, vikunja#395). Open work used to vanish from listings after ttl_days while still sitting on disk waiting for someone — a blind spot rather than a guard, and one that had already caused a queue sweep to find 17 stranded tasks where this tool reported 13. Nothing that is still someone's responsibility should be hidden by a clock: an agent handed a stale open task can judge it, whereas nobody can act on a task they cannot see.
Parked tasks are exempt from the TTL filter. Parking is a deliberate "pause this, I'll come back to it" — a parked task quietly expiring out of the listing would defeat the point of the status.
get_task
get_task(task_id="a7f3d2c1-1234-5678-abcd-000000000000")
# → full task dict, or {"ok": false, "error": "not found"}Searches the main queue first, then archive/. Requires a full UUID — no prefix matching.
update_task
update_task(
task_id="a7f3d2c1-1234-5678-abcd-000000000000",
status="in-progress", # see transition table below
actor="deploy-agent",
note="Claimed task, starting build.",
output=None, # written to result.output on completed/failed
)
# → {"ok": true, "task_id": "<uuid>"} or {"ok": false, "error": "..."}Ownership check (since v0.5.0): actor must equal the task's target_agent, or be
"operator" — any other actor is rejected. This closes the gap where an agent other than
the one a task was assigned to could claim or complete it.
Valid transitions:
From | To |
|
|
|
|
Any non-terminal |
|
Non-terminal: submitted, pending-approval, approved, in-progress, parked, routing-failed.
Terminal: completed, failed, cancelled.
routing-failed is dispatcher-written and deliberately excluded from the Any non-terminal → failed
row above — an agent must not be able to terminally fail a task the dispatcher is still retrying. It
is a normal source for the operator transitions below (cancelled, parked, override).
retry_policy is dispatcher-owned — update_task never touches it.
Operator transitions (set_task_status)
Broader than update_task but still audited and bounded:
From | To | Notes |
|
| standard |
Any non-terminal |
| standard (also via |
Any non-terminal |
| standard (also via |
Any non-terminal | Any non-terminal | requires |
Any unrecognised status | Any valid status | requires |
Terminal tasks are immutable even for operators. Every operator change appends a history entry with actor + note.
The repair path exists because the queue directory has more than one writer. A record whose status is outside this server's vocabulary entirely — a historic complete typo, or a future dispatcher status not yet admitted here — is unreachable by every other branch and would otherwise be permanently stuck. Repair only ever moves a task out of an invalid status; the target must still be valid, and the history entry records repaired_from. routing-failed no longer needs this path — it's a first-class non-terminal status now (see above), reachable via the standard cancelled/parked rows or the plain override row.
park_task / unpark_task
park_task(task_id="...", actor="operator", note="waiting on upstream fix")
# → {"ok": true, "task_id": "<uuid>"}
unpark_task(task_id="...", actor="operator", status=None)
# → returns the task to the status it was parked fromParking changes only the status — the YAML never moves. The task keeps appearing in list_tasks, is exempt from TTL expiry, and nothing picks it up, because the dispatcher's pickup loops match submitted and routing-failed only. The prior status is recorded in parked_from and cleared on the way out, so it can never go stale. Pass status to unpark_task to send the task somewhere other than where it came from — required for a task parked by a direct-YAML writer, which carries no parked_from.
Park is for "not now, but don't lose this". A long-idle task is not necessarily neglect, and parked is the vocabulary that distinguishes a deliberate bookmark from something genuinely abandoned.
amend_task
amend_task(
task_id="...",
amendment="Preflight answered the open question — FastMCP mount() is live-linked.",
actor="research", # the task's source_agent, or "operator"
reason="preflight ran after queuing",
)
# → {"ok": true, "task_id": "...", "amendment_count": 1, "agent_may_have_started": false}Once a task is queued its description is immutable. When something changes between queuing and starting — a preflight answers an open question, a dependency lands, a reviewer spots an error, scope narrows — the correction has nowhere to go, and an agent that trusts its task description will do the wrong thing.
amend_task closes that gap append-only. payload.description is never mutated; amendments accumulate under payload.amendments as {timestamp, actor, reason, text} and readers render them after the description. What the task originally asked for stays on the record.
Rule | Behaviour |
Who may amend | The task's |
When | Any non-terminal task, including |
in-progress | Permitted — it is the case that matters most — but the response sets |
Bounds | 10 amendments per task, 4096 chars each. |
Scope-creep guideline: more than one or two amendments on a task is a signal to cancel and re-queue rather than accrete. The bounds are a backstop, not a budget.
Related MCP server: task-manager-mcp
Status Lifecycle
submitted → [pending-approval] → approved → in-progress → completed
↓
failed
routing-failed # dispatcher-written on a failed dispatch attempt; non-terminal
Any non-terminal ──(operator)──> cancelled # graceful dismissal, record kept
Any non-terminal <──(operator)──> parked # pause; stays listed, TTL-exemptThe dispatcher owns the submitted → approved/pending-approval transitions, and also writes
routing-failed when a dispatch attempt fails (it retries on its own schedule; operators can
also cancel, park, or force it elsewhere via set_task_status). Agents own approved → in-progress → completed (or failed) — routing-failed is not reachable through update_task.
Operators own cancelled, parked, and audited status overrides. Approval gating is controlled
by agent manifests and the requires_approval field.
Every task is closed by its own target agent. That follows from update_task's ownership
check, and it is the one rule to keep in mind when wiring a new cross-agent workflow: the agent
that submits a request cannot close it, because the request targets someone else. A request/return
pair therefore needs the receiving agent to claim and close its own entry — two calls, since
completed is only reachable from in-progress. The auto-close
is the fail-safe for when it doesn't, not a substitute for it.
HTTP Control API
Non-MCP clients (the CloudCLI plugin and Matrix bot) can't import the Python core, so all their mutations go through a thin HTTP control API mounted as FastMCP custom routes on the same port 8485. Each endpoint delegates to the tool handlers above, inheriting transition validation, fcntl locking, and atomic writes — so there is exactly one validated write path for the whole system.
Method | Path | Delegates to |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Counts by status across the active queue |
Body fields: note, plus status / allow_override for the status route, amendment / reason for amend, status / output / on_behalf_of for update. Responses map the canonical result: 200 ok, 404 not found, 400 validation/transition error.
actor is pinned to operator on every one of these routes and is not read from the body (since v0.8.0). It was previously body.get("actor", "operator") — correct in practice, but it made the operator identity something a caller inherited by omission rather than something anyone chose. Pinning means a future non-operator client here cannot quietly acquire the identity that every ownership check exempts.
The operator sweep — POST /tasks/{id}/update
The only path to a terminal transition on another agent's task. It exists because v0.8.0 closed the dishonest version: agents used to tidy up a stranded task by passing that agent's name as actor, which binding actor to a bearer token removes. Nothing else reaches it — set_task_status cannot make terminal transitions and the update_task tool now demands the resolved identity — so without this every stray would need the operator to intervene by hand.
Pass on_behalf_of naming the agent whose task it is. The handler verifies it against the task's actual target_agent (a mismatch is a 400, because an operator closing a task they have misidentified should be told, not have the mistake recorded as deliberate) and writes both names into history:
history:
- timestamp: ...
status: completed
actor: operator
on_behalf_of: developer
note: "stranded; swept during queue cleanup"A sweep should read as a sweep years later, not as the agent having quietly closed its own work. on_behalf_of is optional — omitting it is the operator acting in its own name — and is refused outright for any non-operator actor.
GET /queue/summary returns {"ok": true, "counts": {...}, "active": N, "total": N}, where active is the non-terminal total (now including routing-failed, counted by name). Statuses outside the server's vocabulary entirely are bucketed under "unknown" rather than dropped, so records written by other direct-YAML writers stay visible in the count.
Auth: custom routes bypass the transport's bearer auth, so a shared-secret header is the gate — and these routes are deliberately outside it, because they are the operator surface:
Send
X-Task-Queue-Secret: $TASK_QUEUE_API_SECRETon every mutation.The server compares it in constant time (
hmac.compare_digest) and fails closed (401) when the secret is missing, wrong, or unconfigured.The secret lives in an operator-managed env file outside the repo, injected via
env_fileinto the container and into each client's environment — never committed to source.
Deployment
Docker (production)
services:
task-queue-mcp:
image: task-queue-mcp:latest
container_name: task-queue-mcp
ports:
# The loopback bind is load-bearing, not cosmetic. The MCP transport on this port
# is unauthenticated (see Trust model below), so publishing it as "8485:8485"
# would expose an unauthenticated queue-mutation endpoint to your whole LAN.
- "127.0.0.1:8485:8485"
volumes:
- ~/.claude/task-queue:/task-queue # host queue directory
environment:
- TASK_QUEUE_DIR=/task-queue
# 0.0.0.0 here is the *container-internal* bind and must stay wide, or the port
# mapping above has nothing to forward to. The host-side bind is what limits reach.
- MCP_HOST=0.0.0.0
- MCP_PORT=8485
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
tmpfs: [/tmp]
user: "1000:1000"
restart: unless-stopped
networks:
- agent-netThe container mounts only the task-queue directory read-write. The rest of the filesystem is read-only. /tmp is a tmpfs for transient scratch space.
Claude Code settings.json
{
"mcpServers": {
"task-queue-mcp": {
"type": "url",
"url": "http://localhost:8485/mcp"
}
}
}Environment Variables
Variable | Default | Description |
|
| Path to the task queue directory inside the container |
|
| Bind host for the HTTP server |
|
| Port for the HTTP server |
| — | Shared secret for the HTTP control API. Required for any control-API mutation — fails closed (401) if unset. The MCP tools themselves do not use it. |
| — | Bearer token for one calling agent, e.g. |
Each agent needs its own token — the token is what identifies the caller, so sharing one
between two agents makes attribution meaningless. The server refuses to start on a shared
token, an empty value, a token under 16 characters, or a token minted for the reserved
operator identity. Generate with:
python -c "import secrets; print(secrets.token_urlsafe(32))"Callers present it as a standard bearer header:
headers:
Authorization: "Bearer ${TASK_QUEUE_TOKEN}"Building
docker build -t task-queue-mcp:latest .Development
Requires Python 3.11+.
pip install -e ".[dev]"
# Lint + format (Baseline gate)
ruff check .
ruff format --check .
# Tests with coverage (gate: >=80%)
python -m pytest --cov=src --cov-report=term-missing
# Run server locally against a local task-queue directory
TASK_QUEUE_DIR=~/.claude/task-queue python -m src.serverThe test suite covers every tool and the HTTP control API — validation edge cases, adversarial YAML strings, illegal transitions, the park/unpark round-trip, amend_task authorization (including the rejected target agent), operator-override auditing, out-of-vocabulary status repair, and the shared-secret gate (missing/wrong secret → 401). All writes use yaml.dump — never string interpolation — to prevent YAML injection.
Security
Both surfaces on port 8485 require a credential:
MCP tool path (
/mcp) — a per-agent bearer token, verified by FastMCP'sStaticTokenVerifier. Missing or unknown token → 401. The transport refuses to start with no tokens configured, so this cannot silently fail open.HTTP control routes (
/tasks/...,/queue/summary) — a shared-secret header (X-Task-Queue-Secret, constant-time compare, fail-closed). See HTTP Control API.
The container runs as UID 1000 with cap_drop: ALL, no-new-privileges, and a read-only rootfs (only /task-queue is writable).
Trust model
Until v0.7.0 the MCP tool path was unauthenticated and the README argued that loopback was a sufficient trust boundary. It was not: the port is published and the container joins a shared Docker network, so every container on that network could reach the tool path too. Any of them could call set_task_status, cancel_task, park_task, unpark_task, or amend_task while asserting any actor — including operator, which the ownership checks explicitly exempt. That made completed_by and history[].actor claims rather than evidence. (vikunja#387)
v0.7.0 closes that path. Each agent holds a distinct token, so the token both authenticates the caller and identifies it. There is deliberately no separate identity header: once an agent holds a token it can set any header it likes on a direct request, so a header-derived identity would be a strictly weaker second channel competing with the token-derived one. One source of identity, not two.
What this does and does not buy. It contains a mistaken or prompt-injected agent acting through its own tool surface, and it makes the audit trail mean what it says. It is deliberately not a boundary against an agent that goes looking for credentials: where agents hold a shell tool and run as the same OS user that owns the secret files, any token on the host is readable by any of them. Closing that needs per-agent OS users or a credential broker, and is out of scope for this server.
The operator identity is reachable only from the HTTP control routes. A TASK_QUEUE_TOKEN_OPERATOR is rejected at startup, because operator is exempt from every ownership check and a token minting it on the agent-facing transport would hand its holder the whole queue.
Identity binding (since v0.8.0)
actor is derived from the bearer token, not taken from the caller. Passing a name that does not match the authenticated identity is refused rather than silently corrected — the wrong name in a call is a bug worth surfacing. Omitting it is fine; it is filled in from the token.
This covers source_agent on submit_task too, which is an identity claim and not just a label: the submit-time auto-close decides whether to fire from source_agent/target_agent, so spoofing it would terminally close another agent's task without ever calling update_task.
Tool | Who may call it |
| any authenticated agent ( |
| the task's |
| the task's |
| the task's |
| operator only — refused for any agent identity |
set_task_status is operator-only because its allow_override path moves a task between any two non-terminal statuses, which is how a task gets walked around a transition rule instead of satisfying it. cancel_task is a terminal, irreversible judgement about someone else's work; an agent abandoning its own task marks it failed with a reason via update_task.
Task File Schema
Tasks are YAML files in ~/.claude/task-queue/, named YYYYMMDD-HHMMSS-<uuid-prefix>.yml. All writes are atomic (write to .tmp, then os.rename()). Per-task file locks via fcntl.flock prevent races between concurrent MCP calls and the dispatcher.
For the full schema and lifecycle documentation, see the homelab-agent component doc.
Related
homelab-agent — agent orchestration documentation
task-dispatcher — the dispatcher that routes and gates tasks
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseAqualityDmaintenanceModel Context Protocol server for Task Management. This allows Claude Desktop (or any MCP client) to manage and execute tasks in a queue-based system.10154215MIT
- FlicenseNot gradedqualityCmaintenanceA task manager MCP server that demonstrates all three MCP primitives (tools, resources, prompts). Enables users to manage tasks, read task summaries and details, and run structured planning/review prompts through natural language.
- FlicenseNot gradedqualityCmaintenanceExposes task management (add, list, complete tasks) and document search (RAG) as MCP tools for AI agents.
- FlicenseAqualityCmaintenanceA production-ready MCP server for task management, enabling LLMs to create, list, and manage tasks via tools and resources, with support for local stdio and cloud Streamable HTTP deployment.5
Related MCP Connectors
Project management MCP for AI agents with safe task reads and writes.
Reliable async execution for agent tool calls: schema gating, retries, idempotency, audit trail.
Coordinate multiple AI agents over MCP: atomic claims, leases, shared ledger, handoffs, tasks.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/TadMSTR/task-queue-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server