task-queue-mcp
task-queue-mcp
一个 FastMCP 服务器,将代理任务队列以 MCP 工具接口的形式暴露出来。代理通过类型化、经过校验的工具提交任务、检查状态并记录完成情况,而不是直接写 YAML 文件。
以 Docker 容器形式运行在 8485 端口。全局接入 ~/.claude.json,因此所有 Claude Code 代理会话都可以访问。
工具
工具 | 说明 |
| 以 |
| 列出任务,支持可选过滤条件;已过 TTL 的任务会被排除 |
| 按 UUID 检索单个任务(也会解析已归档任务) |
| 面向代理的状态转换(严格校验);追加一条历史记录 |
| 操作员状态变更 —— 批准、取消、暂停,或推进一个被遗漏的任务(审计覆盖) |
| 将任务置为 |
| 将任务置为 |
| 将任务从 |
| 以追加方式补充任务描述(仅追加,不修改原文) |
代理使用严格的 update_task 路径;操作员(通过 HTTP 控制 API)使用
set_task_status / cancel_task / park_task / unpark_task。代理不能取消或
暂停 —— 这两项仅限操作员。amend_task 是例外:任务的来源代理可以
修改它,但目标代理不能。
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 必须是绝对路径。risk_level 和 priority 会对照允许列表进行校验。workflow_mode 控制调度器行为:semi-auto(默认)将任务加入队列等待操作员领取,并发送 Matrix 通知;而 auto 会触发调度器直接无头启动目标代理。服务器生成 UUID、设置 created,并初始化 retry_policy 存根。
源任务的自动关闭(自 v0.6.0 起)
传入 originating_task_id 后,父任务会以 completed 状态关闭 —— 提交返回任务就是关闭请求的操作。 响应中会新增 auto_closed_task_id 字段,在触发时返回。
仅当以下所有条件成立时才会触发:
条件 | 原因 |
父任务已解析,且未被归档 | 否则没有可关闭的对象 |
| 绑定关系 —— 代理 A 不能通过将代理 B 的任务命名为父任务来关闭它。此处显式检查,而不是依赖 |
| 返回路径的另一半 —— 你必须回答向你提问的一方。没有这一半,转发请求看起来就和返回请求一模一样(见下文) |
父任务处于 |
|
为什么需要这两半(自 v0.6.1 起)。 originating_task_id 存在重载:在返回任务中它表示"这是对那个请求的回答",但在转发请求中它表示"继承此父任务的 workflow_mode" —— 构建代理在为自己的进行中构建提交审计请求时就是这样用的。只检查第一个条件无法区分这两种情况,因为构建任务的目标代理就是构建代理本身,而构建代理就是提交者。v0.6.0 只检查了第一个条件,导致一个进行中的构建任务在一小时内就被关闭了。
真正的返回是对称的;转发则不是:
父任务 | 新任务 | 会触发吗? | |
返回 | audit |
| 会 —— 两半都成立 |
转发 | build | audit | 不会 —— |
一个 approved 的父任务会先经过 in-progress,因此它的历史记录读起来是"已领取-已关闭",而不是"凭空关闭"。
这是一个故障安全机制,不是主路径。代理仍然应该显式关闭自己的任务 —— 那样会在历史中留下代理自己的备注;而自动关闭只写入 auto-closed: return task <id> submitted。自动关闭过程中的任何失败都只记录为 warning 级别,提交操作照常返回;它绝不可能让作为其副作用的提交操作失败。
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 descending无法识别的 status 是错误,不是空结果(自 v0.6.0 起)。 以前它会静默过滤,导致搜索 status="pending" —— 一个这里从未有过的状态 —— 返回 [],和"没有你的工作"无法区分。空列表是对格式良好问题的合法回答,所以区分拼写错误和空队列的唯一办法就是拒绝拼写错误。空白字符和尾随逗号仍然容忍;空字符串仍然表示不过滤。
Terminal 状态且超过 ttl_days 的任务会被排除。调度器对 TTL 归档具有权威性,但 list_tasks 会主动过滤已完成的记录,这样代理就不会对过期条目采取行动。
非 terminal 状态的任务从不受 TTL 过滤(自 v0.8.1 起,vikunja#395)。以前,超过 ttl_days 的未完成任务会从列表中消失,但文件仍然留在磁盘上等待处理 —— 这是一个盲区而非保护措施,曾经导致队列扫描发现 17 个滞留任务,而该工具只报告了 13 个。仍然有人负责的任务不应该被时钟隐藏:代理拿到一个过期的未完成任务时可以自行判断;而一个根本看不到的任务,任何人都无法对其采取行动。
Parked 任务豁免于 TTL 过滤。暂停是一种刻意的"先放一放,我稍后回来处理" —— 如果暂停的任务悄悄过期并从列表中消失,暂停就失去了意义。
get_task
get_task(task_id="a7f3d2c1-1234-5678-abcd-000000000000")
# → full task dict, or {"ok": false, "error": "not found"}先搜索主队列,再搜索 archive/。要求完整 UUID —— 不支持前缀匹配。
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": "..."}所有权检查(自 v0.5.0 起): actor 必须等于任务的 target_agent,或者是
"operator" —— 任何其他参与者都会被拒绝。这堵住了漏洞:以前,非任务指定代理
也可以认领或完成任务。
合法转换:
从 | 到 |
|
|
|
|
任意非 terminal 状态 |
|
非 terminal 状态:submitted、pending-approval、approved、in-progress、parked、routing-failed。
Terminal 状态:completed、failed、cancelled。
routing-failed 由调度器写入,被刻意排除在上面的"任意非 terminal → failed"行之外 —— 代理绝不能终结性地失败一个调度器仍在重试的任务。它
可以作为下面操作员转换(cancelled、parked、覆盖)的合法源状态。
retry_policy 归调度器所有 —— update_task 从不触碰它。
操作员转换(set_task_status)
比 update_task 范围更广,但同样经过审计且有边界:
从 | 到 | 说明 |
|
| 标准操作 |
任意非 terminal 状态 |
| 标准操作(也可通过 |
任意非 terminal 状态 |
| 标准操作(也可通过 |
任意非 terminal 状态 | 任意非 terminal 状态 | 需要 |
任意无法识别的状态 | 任意合法状态 | 需要 |
Terminal 任务即使对操作员也是不可变的。每次操作员变更都会追加一条包含 actor + note 的历史记录。
修复路径的存在是因为队列目录有多个写入者。一条状态完全不在本服务器词汇表内的记录 —— 比如历史遗留的 complete 拼写错误,或者未来调度器新增但此处尚未收录的状态 —— 无法被任何其他分支触达,否则将永远卡死。修复只会将任务移出非法状态;目标状态必须仍然合法,且历史记录会记下 repaired_from。routing-failed 不再需要这条路径 —— 它现在是一等公民的非 terminal 状态(见上文),可以通过标准的 cancelled/parked 行或普通覆盖行触达。
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 from暂停只改变状态 —— YAML 文件从不移动。任务继续出现在 list_tasks 中,豁免于 TTL 过期,也不会被任何东西领取,因为调度器的领取循环只匹配 submitted 和 routing-failed。先前的状态记录在 parked_from 中,并在恢复时清除,因此它永远不会过期。向 unpark_task 传入 status 可以将任务送到来源之外的其他地方 —— 对于由直接写 YAML 的写入者暂停的任务(没有 parked_from),这是必需的。
暂停是给"现在不做,但别丢了"用的。长期闲置的任务不一定就是被忽视,而 parked 正是用来区分刻意书签与真正遗弃的词汇。
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}任务一旦入队,其描述就是不可变的。当从入队到启动之间发生变化时 —— 预检回答了一个悬而未决的问题、一个依赖落地了、评审者发现了一个错误、范围收窄了 —— 修正内容无处可去,而一个信任任务描述的代理就会做错事。
amend_task 以仅追加的方式填补了这个缺口。payload.description 从不被修改;修订内容累积在 payload.amendments 下,格式为 {timestamp, actor, reason, text},读取方在描述之后渲染它们。任务最初要求的内容始终保留在记录中。
规则 | 行为 |
谁可以修改 | 任务的 |
何时 | 任何非终态任务,包括 |
in-progress | 允许——这是最需要关注的情形——但响应会设置 |
上限 | 每个任务最多 10 次修改,每次 4096 字符。 |
范围蔓延指南: 对同一任务修改超过一两次,说明应该取消并重新排队,而不是不断累积。上限是兜底措施,不是预算。
Related MCP server: MCP Task Assistant
状态生命周期
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-exempt调度器拥有 submitted → approved/pending-approval 转换,并在派发尝试失败时写入 routing-failed(它按自己的计划重试;operator 也可以通过 set_task_status 在其他地方取消、暂停或强制处理)。Agent 拥有 approved → in-progress → completed(或 failed)——routing-failed 无法通过 update_task 到达。Operator 拥有 cancelled、parked 以及审计状态覆盖。审批门控由 agent 清单和 requires_approval 字段控制。
每个任务都由其自身的目标 agent 关闭。 这源于 update_task 的所有权检查,也是接入新的跨 agent 工作流时要记住的唯一规则:提交请求的 agent 无法关闭它,因为该请求的目标是其他人。因此,请求/返回配对需要接收方 agent 认领并关闭自己的条目——需要两次调用,因为 completed 只能从 in-progress 到达。自动关闭 是当它未关闭时的故障安全机制,而非替代方案。
HTTP 控制 API
非 MCP 客户端(CloudCLI 插件和 Matrix 机器人)无法导入 Python 核心,因此它们的所有变更都通过一个轻量 HTTP 控制 API 进行,该 API 作为 FastMCP 自定义路由挂载在同一端口 8485 上。每个端点都委托给上述工具处理器,继承转换验证、fcntl 锁和原子写入——因此整个系统只有一条经过验证的写入路径。
方法 | 路径 | 委托给 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 按状态统计活动队列中的数量 |
请求体字段:note,加上状态路由的 status / allow_override,修改路由的 amendment / reason,更新路由的 status / output / on_behalf_of。响应映射规范结果:200 成功,404 未找到,400 验证/转换错误。
actor 在这些路由上全部固定为 operator,且不从请求体读取(自 v0.8.0 起)。此前是 body.get("actor", "operator")——实践中是正确的,但它使 operator 身份成为调用者因省略而继承的东西,而非任何人主动选择的东西。固定意味着未来此处的非 operator 客户端无法悄然获得每个所有权检查都豁免的身份。
Operator 清扫——POST /tasks/{id}/update
这是对另一个 agent 的任务进行终态转换的唯一路径。它之所以存在,是因为 v0.8.0 关闭了不诚实的做法:agent 过去通过传入该 agent 的名称作为 actor 来清理搁浅的任务,而将 actor 绑定到持有者令牌后这一做法被移除。没有其他方式可以到达终态——set_task_status 无法进行终态转换,update_task 工具现在要求解析后的身份——因此没有这个端点,每个遗留任务都需要 operator 手动干预。
传入 on_behalf_of 指明任务所属的 agent。处理器会将其与任务实际的 target_agent 进行验证(不匹配则返回 400,因为 operator 关闭一个其识别错误的任务应当被告知,而不是将错误记录为有意行为),并将两个名称都写入历史:
history:
- timestamp: ...
status: completed
actor: operator
on_behalf_of: developer
note: "stranded; swept during queue cleanup"多年后,一次清扫应当读起来像一次清扫,而不是 agent 悄然关闭了自己的工作。on_behalf_of 是可选的——省略它表示 operator 以自己的名义行事——并且对任何非 operator 的 actor 一律拒绝。
GET /queue/summary 返回 {"ok": true, "counts": {...}, "active": N, "total": N},其中 active 是非终态总数(现在包括 routing-failed,按名称计数)。服务器词汇表之外的状态被归入 "unknown" 桶而非丢弃,因此由其他直接 YAML 写入者写入的记录在计数中仍然可见。
认证: 自定义路由绕过传输层的 bearer 认证,因此共享密钥头是门禁——这些路由刻意位于其外,因为它们是 operator 的操作面:
每次变更都发送
X-Task-Queue-Secret: $TASK_QUEUE_API_SECRET。服务器以恒定时间(
hmac.compare_digest)进行比较,并在密钥缺失、错误或未配置时故障关闭(401)。密钥存放在仓库外由 operator 管理的环境文件中,通过
env_file注入容器和每个客户端的环境——绝不提交到源码。
部署
Docker(生产环境)
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-net容器仅以读写方式挂载任务队列目录。文件系统的其余部分为只读。/tmp 是用于临时暂存空间的 tmpfs。
Claude Code settings.json
{
"mcpServers": {
"task-queue-mcp": {
"type": "url",
"url": "http://localhost:8485/mcp"
}
}
}环境变量
变量 | 默认值 | 描述 |
|
| 容器内任务队列目录的路径 |
|
| HTTP 服务器的绑定主机 |
|
| HTTP 服务器的端口 |
| — | HTTP 控制 API 的共享密钥。任何控制 API 变更必需——未设置则故障关闭(401)。MCP 工具本身不使用它。 |
| — | 某个调用 agent 的 bearer 令牌,例如 |
每个 agent 都需要自己的令牌——令牌就是标识调用者的凭据,因此两个 agent 共享一个令牌会使归属变得毫无意义。服务器在共享令牌、空值、少于 16 个字符的令牌或为保留的 operator 身份铸造的令牌下拒绝启动。使用以下命令生成:
python -c "import secrets; print(secrets.token_urlsafe(32))"调用者以标准 bearer 头呈现它:
headers:
Authorization: "Bearer ${TASK_QUEUE_TOKEN}"构建
docker build -t task-queue-mcp:latest .开发
需要 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.server测试套件覆盖每个工具和 HTTP 控制 API——验证边界情况、对抗性 YAML 字符串、非法转换、暂停/恢复往返、amend_task 授权(包括被拒绝的目标 agent)、operator 覆盖审计、词汇表外状态修复,以及共享密钥门禁(缺失/错误密钥 → 401)。所有写入都使用 yaml.dump——绝不使用字符串插值——以防止 YAML 注入。
安全
端口 8485 上的两个操作面都需要凭据:
MCP 工具路径(
/mcp)——每个 agent 一个 bearer 令牌,由 FastMCP 的StaticTokenVerifier验证。缺失或未知令牌 → 401。传输层在未配置任何令牌时拒绝启动,因此这不会静默地开放失败。HTTP 控制路由(
/tasks/...、/queue/summary)——共享密钥头(X-Task-Queue-Secret,恒定时间比较,故障关闭)。参见 HTTP 控制 API。
容器以 UID 1000 运行,带有 cap_drop: ALL、no-new-privileges 和只读根文件系统(仅 /task-queue 可写)。
信任模型
直到 v0.7.0,MCP 工具路径都是未认证的,README 声称回环是足够的信任边界。事实并非如此:端口是发布的并且容器加入了共享的 Docker 网络,因此该网络上的每个容器也都能到达工具路径。其中任何一个都可以在断言任意 actor(包括所有权检查明确豁免的 operator)的同时调用 set_task_status、cancel_task、park_task、unpark_task 或 amend_task。这使得 completed_by 和 history[].actor 成为声明而非证据。(vikunja#387)
v0.7.0 关闭了该路径。每个 agent 持有不同的令牌,因此令牌既认证调用者又标识调用者。刻意没有单独的身份头:一旦 agent 持有令牌,它就可以在直接请求上设置任意头,因此基于头的身份将是与基于令牌的身份竞争的严格更弱的第二通道。一个身份来源,而非两个。
它提供什么,不提供什么。它针对的是通过自身工具面行动的被误导或提示注入的智能体,并让审计日志名实相符。它特意不是针对寻找凭据的智能体的防线:当智能体持有 shell 工具并以拥有机密文件的同一操作系统用户身份运行时,主机上的任何令牌都可被其中任何一个智能体读取。要堵住这一漏洞,需要为每个智能体设置独立的操作系统用户或凭据代理,这超出了本服务器的范围。
operator 身份只能通过 HTTP 控制路由访问。TASK_QUEUE_TOKEN_OPERATOR 会在启动时被拒绝,因为 operator 豁免于所有所有权检查,而在面向智能体的传输通道上铸造此令牌等同于将整个队列交给持有者。
身份绑定(自 v0.8.0 起)
actor 从持有者令牌中派生,而非取自调用方。传入与已验证身份不匹配的名称会被拒绝而非静默更正——调用中出现错误的名称是值得暴露的缺陷。省略该字段没有问题;它会从令牌中自动填充。
这也涵盖了 submit_task 上的 source_agent,它是一项身份声明而非仅仅是标签:提交时的自动关闭决定是否从 source_agent/target_agent 触发,因此伪造它将无需调用 update_task 即可终结性地关闭另一智能体的任务。
工具 | 谁可以调用它 |
| 任何已认证的智能体( |
| 任务的 |
| 任务的 |
| 任务的 |
| 仅限操作员——对任何智能体身份均拒绝 |
set_task_status 仅限操作员调用,因为其 allow_override 路径可将任务在任意两个非终结状态之间移动,这正是绕过转换规则而非满足规则的方式。cancel_task 是对他人工作做出的终结性、不可撤销的裁决;智能体放弃自身任务时应通过 update_task 将其标记为 failed 并附上原因。
任务文件架构
任务是以 YAML 格式存储于 ~/.claude/task-queue/ 中的文件,命名为 YYYYMMDD-HHMMSS-<uuid-prefix>.yml。所有写入均为原子操作(先写入 .tmp,再执行 os.rename())。通过 fcntl.flock 实现的按任务文件锁可防止并发 MCP 调用与调度器之间的竞争。
完整架构与生命周期文档,请参阅 homelab-agent 组件文档。
相关链接
homelab-agent——智能体编排文档
task-dispatcher——负责路由和门控任务的调度器
This server cannot be deployed
Maintenance
Related MCP Connectors
Project management MCP for AI agents with safe task reads and writes.
Local-first task manager: create, edit, and complete tasks, projects, and checklists via MCP.
- DazbenchOAuthapp.dazbench
Task management your AI agents can actually run. One line becomes a context-ready task over MCP.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Related MCP Servers
- AlicenseAqualityFmaintenanceModel Context Protocol server for Task Management. This allows Claude Desktop (or any MCP client) to manage and execute tasks in a queue-based system.10864 npm216MIT
- FlicenseNot gradedqualityDmaintenanceExposes task management (add, list, complete tasks) and document search (RAG) as MCP tools for AI agents.-
- FlicenseNot gradedqualityCmaintenanceExposes a FastAPI task management REST API as MCP tools, enabling an LLM client to list, create, get, complete, and delete tasks via natural language.-
- AlicenseAqualityAmaintenanceEnables AI agents to manage tasks on a local-first board via MCP, exposing task creation, updates, and queries through a thin adapter over the REST service.7MIT