内容
# Owl 任务调度器 MCP
**中文** | [繁體中文](README.zh-TW.md)
一个可自行托管的、多租户的 MCP 服务器,它将自然语言聊天转化为持久的调度任务。你告诉 Claude 或 Codex “每个工作日的 9 点,总结我的 GitHub 问题并将它们发布到 Slack”,然后即使你关闭聊天,任务也会按计划继续运行。支持 Postgres 和 SQS,在一个 5 美元的 VPS 上运行。
[](https://github.com/PaynePew/task_scheduler_mcp/actions) [](https://scheduler.paynepew.dev) [](https://status.paynepew.dev)
---
## 什么是 Owl 任务调度器
大多数 MCP 服务器通过 stdio 运行,因此它们随着聊天窗口的关闭而启动和停止。调度器不能这样工作:一个定时为 “每周一 10 点”的任务必须在有或没有聊天窗口的情况下运行。因此,它作为一个持久的 HTTP 服务运行,拥有自己的数据库和工作线程池。聊天客户端只是通过 MCP 创建和查询任务;服务器根据实际时间调度任务。
一个多租户实例在 [scheduler.paynepew.dev](https://scheduler.paynepew.dev) 运行。它通过 OAuth 2.1 对每个用户进行身份验证,使用用户自己的作用域令牌运行操作,并通过配额、速率限制和负载削减来保护一个小型盒子。你可以在大约两分钟内连接它,或者克隆仓库并自行运行整个堆栈。
**支持的客户端:** Claude Desktop, Claude Code, Codex CLI, Cursor, MCP Inspector.
## 关键技术要点
- 持久性是关键。任务在聊天会话结束后仍然存在,因为调度器是一个长寿命的 HTTP 服务,拥有自己的数据库,而不是一个随客户端退出而终止的子进程([ADR-006](docs/adr/ADR-006-mcp-transport-dual-stdio-http.md))。
- 真正的身份验证,而不是一个头部。公共端点是一个 OAuth 2.1 资源服务器,授权服务器使用 WorkOS AuthKit。用户的身份是一个经过验证的 JWT 主体,因此一个用户永远无法读取或取消另一个用户的任务([ADR-053](docs/adr/ADR-053-layer1-authorization-server-workos-authkit.md), [ADR-049](docs/adr/ADR-049-public-product-multi-tenant-oauth-delegation.md))。
- 你的原始密钥永远不会被存储。GitHub、Slack 和 Gmail 操作使用每个用户的作用域 OAuth 令牌,这些令牌是加密的、可撤销的,并在需要时自动刷新([ADR-054](docs/adr/ADR-054-layer2-token-storage-aws-kms-envelope-encryption.md))。
- 数据模型旨在避免竞争。每个状态更改都写入一个仅追加的出队日志,位于同一事务中,接着后续的使用者读取该事件日志,而不是轮询可变状态([ADR-009](docs/adr/ADR-009-database-schema-outbox.md))。循环或链式运行仅在上游运行达到终端事件时创建 - *延续* - 并且通过 Postgres 唯一索引强制执行一次([ADR-067](docs/adr/ADR-067-continuation-chaining-replaces-pre-armed-waiting-runs.md))。
- 它运行在 5 美元/月并在负载下保持运行。监视器使用 `FOR UPDATE SKIP LOCKED` 声明工作,因此几个监视器可以在没有领导者选举的情况下运行。服务器在边缘减少负载,限制并发,并在队列深度时施加反压力([ADR-007](docs/adr/ADR-007-watcher-ha-skip-locked.md), [ADR-057](docs/adr/ADR-057-overload-protection-load-shedding-concurrency.md))。
- 崩溃的工作线程不会阻塞任务。每个 `RUNNING` 运行都会更新 `heartbeat_at` 租约,因此协调器可以将缓慢但活跃的运行与已死亡的运行区分开来,并仅恢复真正的孤儿 - 重新运行幂等操作并安全地失败非幂等操作 - 这释放了任务的配额并恢复了其重复性。`email_send` *实际上是一次性的*:运行派生的幂等键加上预写意图记录使重新传递的操作成为无操作,而不是重复。至少一次交付 + 幂等消费者是对外部副作用的诚实保证([ADR-069](docs/adr/ADR-069-running-orphan-recovery-and-heartbeat-lease.md), [ADR-070](docs/adr/ADR-070-email-send-effectively-once-posture.md))。
- 每个决定都被记录下来。60 多个 ADR 涵盖了范围、语言、数据存储、传输、身份验证和安全模型,因此推理是可审计的而不是部落式的。
## 架构
两个视图。第一个是任务生命周期:请求如何成为按时触发的调度运行。第二个是身份验证和机密握手,在任何操作之前发生。
### 运行时
```mermaid
flowchart LR
CL["MCP 客户端<br/>Claude · Codex · Cursor"]
SRV["mcp-server<br/>OAuth 2.1 资源服务器<br/>验证 · 速率限制 · 并发"]
PG[("Postgres<br/>任务 · 任务运行<br/>运行事件输出队列")]
WAT["监视器<br/>SKIP LOCKED"]
Q[("队列<br/>SQS · ElasticMQ")]
WO["工作线程<br/>操作处理程序<br/>续租心跳租约"]
EXT["外部 API<br/>GitHub · Slack · Gmail<br/>LLM · HTTP · ICS"]
CONT["延续消费者<br/>循环后继 +<br/>链式下游"]
REC["协调器<br/>扫描 A · B · C<br/>孤儿恢复"]
CL -->|MCP 调用| SRV
SRV -->|持久化任务| PG
PG -->|到期运行| WAT
WAT -->|加入队列| Q
Q --> WO
WO -->|调度| EXT
WO -->|结果 + 终端事件| PG
PG -. 终端事件 .-> CONT
CONT -. 创建下一次运行 .-> PG
PG -. 过期租约孤儿 .-> REC
REC -. 恢复 / 故障安全 .-> PG
```
工具调用通过 Caddy 达到 `mcp-server`。服务器验证持有者令牌,检查调用者的速率限制和配额,并写入一个 `Job`(以及对于调度任务,其第一个 `JobRun` - 链式任务在触发器触发之前不会运行)。**监视器**扫描在接下来的五分钟内到期的运行,并使用 `FOR UPDATE SKIP LOCKED` 声明它们,这样多个监视器可以在不相互干扰的情况下运行。被声明的运行被加入队列,**工作线程**拉取一个,调度到匹配的类型操作处理程序,然后将结果和状态事件写回 Postgres。
后续消费者永远不会轮询可变状态列 - 它们读取仅追加的 `run_events` 输出队列。**延续消费者**对每个终端事件做出反应:在同一事务中,它物化下一个循环出现 *和* 为每个链式下游创建 `PENDING` 运行(延续),然后结算已完成的任务的 `Job.state`。它取代了旧的 `RecurringJobWatcher` + `ChainWatcher` 对(ADR-067)。**协调器** 是最后的防御线:三个幂等扫描在 `SKIP LOCKED` 下运行 - 它失败卡住的 `RETRYING` 运行(A),重新排队因队列已满而被丢弃的 `QUEUED` 运行(B),并恢复被工作线程崩溃的 `RUNNING` 运行(C)。它通过工作线程在持有运行期间续租的心跳租约来区分已死亡的工作线程和缓慢的工作线程,并通过操作的可幂性姿态恢复每个孤儿 - 重新运行可以安全重复的操作,失败安全并警报不可以的操作 ([ADR-069](docs/adr/ADR-069-running-orphan-recovery-and-heartbeat-lease.md))。
### 身份验证和机密
```mermaid
sequenceDiagram
actor U as 用户
participant C as MCP 客户端
participant S as mcp-server
participant W as WorkOS AuthKit
participant DB as Postgres + KMS
C->>S: 没有令牌的 MCP 调用
S-->>C: 401 + WWW-Authenticate (PRM URL)
C->>W: OAuth 2.1 登录浏览器
W-->>C: 持有者 JWT
C->>S: 带有持有者令牌的 MCP 调用
S->>W: 验证 JWT 对 JWKS
S-->>C: 可用工具
U->>S: 连接 GitHub / Slack / Google
S->>DB: 存储 OAuth 令牌,KMS 加密
Note over S,DB: 工作线程稍后读取用户的令牌以运行操作
```
未经身份验证的调用会收到一个 `401` 和一个 `WWW-Authenticate` 挑战,该挑战指向受保护资源元数据端点(RFC 9728),这是客户端发现登录流程的方式。在浏览器登录后,客户端发送一个 WorkOS 持有者 JWT,服务器会根据 JWKS 验证该令牌。连接应用程序是在 `/connections` 上针对每个提供者的单独 OAuth 同意;生成的令牌被加密并存储,工作线程在运行时读取它。
## 技术栈
| 层 | 选择 |
|---|---|
| 语言和工具 | Python 3.12,[uv](https://docs.astral.sh/uv/) 用于依赖/虚拟环境,[ruff](https://docs.astral.sh/ruff/) 校验 + 格式化 |
| MCP | 官方 [`mcp`](https://github.com/modelcontextprotocol/python-sdk) Python SDK — 双重传输:stdio + 可流式 HTTP |
| Web / API | Starlette + uvicorn (ASGI) 用于 HTTP 传输,`/connections` OAuth 仪表板和静态登录页面 |
| 数据 | PostgreSQL 16,SQLAlchemy 2.0 异步(asyncpg),Alembic 迁移,一个仅追加的 `run_events` 输出队列 |
| 队列 | 生产环境中的 Amazon SQS,本地环境中的 [ElasticMQ](https://github.com/softwaremill/elasticmq) — 相同的 boto3 客户端 |
| 身份验证 | [WorkOS AuthKit](https://workos.com/) 作为 OAuth 2.1 授权服务器;该应用程序是一个资源服务器(PyJWT + JWKS,RFC 8707/9728) |
| 机密 | AWS KMS 信封加密用于每个用户的 OAuth 令牌(boto3 + cryptography) |
| 调度和配置 | `croniter` 用于 cron 扩展;pydantic v2 + pydantic-settings |
| 操作输入/输出 | `httpx`(GitHub/Slack/Gmail/LLM),`icalendar`(ICS),操作员资助的 LLM 固定为 `gpt-4o-mini` |
| 可观察性 | 结构化 JSON 日志(`python-json-logger`)→ Better Stack;`/healthz` 报告正在运行的提交 SHA |
| 基础设施 | Docker Compose(8 个服务),Caddy(自动 TLS),AWS Lightsail(东京,约 5 美元/月),Cloudflare R2(夜间备份) |
| CI/CD | GitHub Actions — 校验 + 单元测试 + 集成测试,然后在 `main` 通过 SSH 自动部署到 VPS |
| 测试 | pytest(+asyncio,+cov),moto(AWS 模拟),aiosmtpd(SMTP 捕获) |
## 快速开始
有三种连接方式,它们使用不同的传输和不同的身份来源。混合使用它们是最常见的设置错误,因此请选择一条路径并一直遵循。
| | A. 主机 | B. 自托管(HTTP) | C. 自托管(stdio) |
|--------------------|------------------------------------------|-------------------------------------|--------------------------------------------|
| MCP 传输 | 可流式 HTTP | 可流式 HTTP | stdio 子进程 |
| MCP 端点 | `https://scheduler.paynepew.dev/mcp` | `http://localhost:8000/mcp` | `uv run python -m app.entrypoints.mcp_stdio` |
| 你是谁 | WorkOS OAuth(验证 JWT `sub`) | `X-User-Id` 头部(仅限信任) | `MCP_USER_ID` 环境变量(仅限信任) |
| 连接应用 | `scheduler.paynepew.dev/connections` | `localhost:8000/connections` | `localhost:8000/connections` |
| 先运行什么 | 无 | `docker compose --profile full up -d` | 相同的 compose 堆栈,然后按需 stdio |
> 仅限信任的 `X-User-Id` 和 `MCP_USER_ID` 路径相信你告诉它们的内容。它们在你的机器上是好的,但永远不要将它们暴露在公共互联网上:任何猜测头部的人都可以读取你的作业。托管路径使用真正的 OAuth。
当操作需要一个你尚未设置的 OAuth 连接时,服务器会回复一个 `MISSING_CONNECTION` 错误和一个由其自己的 `CONNECTIONS_BASE_URL` 构建的 `connect_url`。如果你的客户端指向托管服务器但你尝试在本地服务器上连接应用(或反之亦然),两个侧面持有不同的身份,每个操作都静默失败。保持两个端点在同一路径上。
### A. 主机:无需安装
这是试用的最快方式。两分钟,无需克隆。
**Claude Desktop** (设置,连接器,添加自定义连接器):命名为 `owl-scheduler`,网址为 `https://scheduler.paynepew.dev/mcp`,其他留空。点击连接,通过浏览器窗口登录,工具出现在聊天中。完整演练:[docs/guides/claude-desktop-quickstart.md](docs/guides/claude-desktop-quickstart.md)。
**Claude Code:**
```bash
claude mcp add --transport http owl-scheduler https://scheduler.paynepew.dev/mcp
```
**Codex CLI** (`~/.codex/config.toml`):
```toml
[mcp_servers.owl-scheduler]
url = "https://scheduler.paynepew.dev/mcp"
```
然后运行 `codex mcp login owl-scheduler` 进行浏览器登录。
登录后,打开 [scheduler.paynepew.dev/connections](https://scheduler.paynepew.dev/connections),检查顶部名称是否与您刚刚使用的帐户匹配,然后根据需要点击连接 GitHub、Slack 或 Google。健康检查:`curl https://scheduler.paynepew.dev/healthz` 返回 `{"ok":true,"db":"connected"}`。
### B. 自托管 HTTP:生产路径
```bash
git clone https://github.com/PaynePew/task_scheduler_mcp
cd task_scheduler_mcp
cp .env.docker.example .env.docker # compose 读取此文件,而不是 .env
cp .env.example .env # 仅用于主机侧 uv(测试,alembic)
docker compose --profile full up -d
```
这将启动八个服务:Postgres、ElasticMQ、一次性迁移器、`mcp-server`、监视器、工作线程、延续消费者(`recurring-watcher` 服务)和协调器。
将您的客户端指向本地端点。`X-User-Id` 头部是信任模式下的身份。
```bash
# Claude Code
claude mcp add --transport http owl-scheduler http://localhost:8000/mcp --header "X-User-Id: me"
```
```toml
# Codex CLI 配置:~/.codex/config.toml
[mcp_servers.owl-scheduler]
url = "http://localhost:8000/mcp"
http_headers = { "X-User-Id" = "me" }
```
打开 `http://localhost:8000/connections`,确认它显示 `Signed in as me`(与您的头部匹配),然后连接每个提供者。要将其放在公共互联网上,请编辑 `.env.docker`:
- 设置 `CONNECTIONS_BASE_URL=https://yourdomain.tld`,以便 OAuth 回调和元数据使用公共主机。
- 配置 WorkOS,使公共端点需要真正的持有者令牌,而不是仅限信任的头部。
- 在一个全新的 Ubuntu 24.04 盒子上,`bin/setup-vps.sh` 安装 Docker、Caddy 自动 TLS、防火墙、夜间 Postgres 备份和 systemd 自动重启。
使用 `http_call` 带来您自己的 LLM:设置 `action: "http_call"`,在头部或正文中引用 `${ANTHROPIC_API_KEY}`(在运行时替换,[ADR-032](docs/adr/ADR-032-secrets-aware-action-handlers-and-env-var-substitution.md)),并将变量名添加到 `ALLOWED_TEMPLATE_VARS`。每个用户的创建限制默认为每天 100 个作业,突发限制为 5 分钟,均可配置([ADR-055](docs/adr/ADR-055-public-abuse-cost-containment-posture.md))。
### C. 自托管 stdio:检查器和开发便利
stdio MCP 服务器是一个在聊天关闭时终止的 subprocess(有关为什么通常不适用于调度器,请参见下文)。将其用于 MCP 检查器调试或短期开发运行。OAuth 仪表板仍然存在于 HTTP 网络层,因此您仍然需要启动堆栈:
```bash
cp .env.docker.example .env.docker
cp .env.example .env
docker compose --profile full up -d # web 层用于 /connections,以及 Postgres 和队列
```
```toml
# Codex CLI 配置:~/.codex/config.toml
[mcp_servers.owl-scheduler]
command = "uv"
args = ["run", "python", "-m", "app.entrypoints.mcp_stdio"]
cwd = "/path/to/task_scheduler_mcp"
env = { MCP_USER_ID = "me", MCP_USER_TZ = "UTC" }
```
```jsonc
// Claude Desktop / Cursor:客户端按需生成进程
{ "mcpServers": { "owl-scheduler": {
"type": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "app.entrypoints.mcp_stdio"],
"env": { "MCP_USER_ID": "me", "MCP_USER_TZ": "UTC" }
}}}
```
stdio 进程和 web 层都从其自己的环境中读取 `MCP_USER_ID`。它们必须解析为相同的字符串,否则您将一个用户连接到 stdio 进程,而 web 层查询另一个用户。打开 `http://localhost:8000/connections` 并确认 `Signed in as me` 与您传入的 `MCP_USER_ID` 匹配。
通过 stdio 入口点使用 MCP 检查器:
```bash
MCP_USER_ID=local-dev MCP_USER_TZ=UTC \
npx @modelcontextprotocol/inspector uv run python -m app.entrypoints.mcp_stdio
```
### 为什么是 HTTP 而不是 stdio
stdio MCP 服务器是聊天客户端的子进程,因此在聊天关闭时终止。调度器必须在时钟时间触发,而不管哪个客户端处于打开状态,这只有长寿命服务才能做到。该代码库同时保留了两种传输方式,因为 stdio 对于本地调试和操作员自己的低摩擦访问确实很有用。参见 [ADR-006](docs/adr/ADR-006-mcp-transport-dual-stdio-http.md)。
## MCP 接口
**工具(5):** `task.create.v1`,`task.list.v1`,`task.status.v1`,`task.cancel.v1`,`task.list_actions.v1`。工具是 LLM 客户端调用的对象;`task.create.v1`接受一个 `action` 字段,指定以下处理程序之一。
**操作(6):** 实际由工作程序执行的操作,按获取凭证的方式分组。所有六种操作都可供每个用户使用。
| 操作 | 需要 | 功能 |
|---|---|---|
| `github_digest` | GitHub 账户 | 拉取指定仓库的 issues 和 PRs。适合作为摘要的上游。 |
| `slack_post` | Slack 账户 | 向指定工作空间的频道发送消息。 |
| `email_send` | Google 账户 | 从 Gmail 发送邮件。支持摘要链接。实际上,一次重新发送的邮件会被重复扣除,不会重复计费。 |
| `llm_summarize` | 无需凭证 | 总结文本或上游结果;链接到 `slack_post` 或 `email_send` 以交付输出。固定提示、代币和预算上限。 |
| `llm_polish` | 无需凭证 | 以更清晰的方式重写文本(语气和语言);链接到 `slack_post` 或 `email_send` 以交付输出。固定提示、代币和预算上限。 |
| `echo` | 无需凭证 | 回显输入。创建和调度测试。 |
OAuth 支持的操作在每个用户的独立作用域令牌上运行。两个 LLM 操作运行固定、成本上限的转换:没有自由形式的提示,也没有 `${VAR}`。模型固定为廉价模型(`gpt-4o-mini`),具有硬性每调用输出代币限制和每用户每日及全局每月预算上限,因此成本保持在限定范围内([ADR-052](docs/adr/ADR-052-operator-subsidized-llm-actions-fixed-prompt-and-caps.md))。它们的输出通过内部数据平面馈送到下游处理程序 - 不会将 MCP 接口返回给调用者。链接 `llm_polish` 或 `llm_summarize` 到 `slack_post` 或 `email_send` 以实际接收结果。
> 自托管额外功能(仅限操作员):`http_call` 和 `calendar_digest_ics` 仅供部署者自己使用。它们仍然出现在 `task.list_actions` 中,但对于托管实例,`task.create` 拒绝除操作员以外的所有人调用它们([ADR-051](docs/adr/ADR-051-action-surface-tiering-public-oauth-vs-operator-only.md))。
**资源(4):** `tasks://list`,`tasks://actions`,`tasks://job/{job_id}`,`tasks://recent-results`(最近 24 小时的已完成运行,有助于连接简介)。
**提示(2):** `daily_review`,`setup_summary`。
**调度功能:** 立即执行、一次性执行(`scheduled_at`)和循环执行(`cron_expr`,包括 `@daily`/`@hourly` 快捷方式);作业链接(`trigger_on_job_id` 带有 `trigger_on_status` 谓词 - `SUCCEEDED` / `FAILED` / `ANY`),可扩展和向上游输出下游;`task.status` 显示链接作业的 `triggered_by` 父级;取消语义(取消上游停止其整个下游流水线);每个用户速率限制和配额。
## 示例提示
用自然语言与系统交互。首先在 `/connections` 连接 GitHub、Slack 和 Google,然后将 `<owner>/<repo>` 和频道/邮箱替换为自己的。如果客户端使用内置调度器,请以 **"使用 owl-scheduler 执行 …"** 开头。
以下步骤从一行测试逐步升级到自我触发的多服务流水线。相同的顺序适用于 **实时演示**(每个环节都建立在前一个环节的基础上)和克隆后的首次运行游览。
### 预热 - 无需账户
1. **发现** - *"使用 owl-scheduler,您能做什么?*"
`task.list_actions` - 列出每个操作以及哪些操作需要连接。
2. **回声测试** - *"立即回声 'hello from owl-scheduler'。*"
`immediate` + `echo`。证明创建 → 调度 → 结果结束到结束。验证:`task.status` 在几秒内显示 `completed`。
### 单个操作
3. **立即摘要** - *"立即拉取 `<owner>/<repo>` 的开放 issues 和 stale PRs,并总结它们。"* (需要 GitHub)
`immediate` + `github_digest`。只读 - 没有数据离开系统。验证:约 10 秒内通过 `task.status` 查看结果。
4. **LLM 精炼 → Slack** - *"立即将此粗糙的发布说明 - '修复了登录漏洞,添加了暗黑模式,API 现在更快' - 重写为专业公告,并发布到 Slack `#announcements` 频道。"* (需要 Slack)
`immediate` + `llm_polish → slack_post`。`llm_polish` 是运营商资助的固定提示、成本上限的重写 - **无需 API 密钥**。其输出永远不会通过 MCP 接口返回;它通过链接流向 `slack_post`。验证:约 15 秒内,一条精炼的消息出现在 `#announcements` 中;Slack 作业的 `task.status` 显示 `triggered_by` 精炼作业。
### 链接 - 精彩展示
5. **一次性三服务链接(旗舰)** - *"3 分钟内,拉取 `<owner>/<repo>` 的开放 issues,发布摘要到 Slack `#eng-updates`,并在成功后将同一摘要通过电子邮件发送给我。"*
`one-shot` + `github_digest → slack_post → email_send`,使用 `from_run_id` + `digest_v1` 模板在数据上游。验证:3 分钟后,Slack 消息出现,然后是电子邮件;`task.status` 显示电子邮件作业的 `triggered_by` 指向 Slack 作业。
6. **扇出** - *"2 分钟内,摘要 `<owner>/<repo>` 并发布到 Slack `#eng-updates` 以及通过电子邮件发送给我。"*
一个上游,两个下游并行创建。验证:Slack 和电子邮件均到达;`task.list` 显示三个作业,其中两个由摘要触发。
7. **仅失败提醒(谓词)** - *"立即运行 `<owner>/<repo>` 的摘要,如果失败,通过电子邮件提醒我。"*
`trigger_on_status=FAILED` - 仅当上游终端状态与谓词匹配时,才创建下游。成功时不发送(审计,但不运行);切换到 `SUCCEEDED` 或 `ANY` 以获取其他策略。
### 循环和调度
8. **每日站会** - *"在台北时间每天 9:00 AM,发布 `<owner>/<repo>` 的开放 issues 和 stale PRs 到 Slack `#standup` 频道。"*
`recurring`(cron)+ `github_digest → slack_post`。**实时演示提示:** 说 *"每 2 分钟"* 以观察其重复运行,然后 *"取消该任务"* 以显示取消语义。
9. **每周报告** - *"每周五 5:00 PM,总结 `<owner>/<repo>` 本周的 GitHub 活动,并通过电子邮件发送报告给我。"*
每周 `recurring` + `github_digest → email_send`(`digest_v1`)。
10. **管理调度** - *"列出我的已调度任务" · "作业 `<id>` 的状态及其运行情况?" · "取消作业 `<id>`。*
`task.list` / `task.status`(带有运行 + `triggered_by`)/ `task.cancel` - 尽力而为;取消上游停止其整个下游流水线。
**实时演示路径(≈5 分钟):** `1 → 2 → 4`(LLM 到 Slack,瞬间 wow)`→ 5`(旗舰链接,核心)`→ 8`,每 2 分钟循环一次,然后取消。提示 1-2 无需账户,因此即使在采访中途连接出现问题,它们仍然有效。旗舰是重磅炸弹:一句话变成一个调度的 **GitHub → Slack → Gmail** 工作流,它可以自行触发并保持完整的审计跟踪。
## 调度工作原理
系统存储三个内容,混淆它们是 bug 的常见原因。**作业(Job)** 是任务定义(执行什么,何时执行,谁拥有)。**作业运行(JobRun)** 是该作业的一次执行尝试。**运行事件(RunEvent)** 是一个不可变的运行状态转换记录。循环作业随时间有多个 **作业运行**;一次性作业有且仅有一个。
客户端看到五个简单状态(`scheduled`、`running`、`completed`、`failed`、`cancelled`)。内部数据库保持更细致的七状态运行机(`PENDING → QUEUED → RUNNING → SUCCEEDED | FAILED | RETRYING`,加上 `CANCELLED`),并将其映射到 MCP 接口边界([ADR-014](docs/adr/ADR-014-mcp-tool-surface-v1.md)),因此精确的事实存储在数据层,而 LLM 获得一个可以推理的模型。每个 **作业** 还具有自己的生命周期 - `active`、`completed` 或 `cancelled` - 就是这个生命周期用于用户配额计数,因此已完成的作业停止消耗活跃作业预算([ADR-068](docs/adr/ADR-068-job-state-machine-replaces-active-boolean.md))。
链接和循环共用一个规则:仅当上游运行达到终端事件时,才创建后续运行 - **延续**,由单个消费者读取事件顺序的出站箱。 确切一次是数据层面的保证:重新传递的事件重试创建命中 Postgres 唯一索引并成为无操作,因此不需要分布式锁。如果上游在下游完成之前终止,重叠的 tick 会故意跳过并审计,最多保留一个正在执行的作业运行每个作业。这取代了早期的预装 `WAITING` / `ChainWatcher` 设计及其双重 spawn 竞赛;推理在 [ADR-067](docs/adr/ADR-067-continuation-chaining-replaces-pre-armed-waiting-runs.md) 和 [CONTEXT.md](CONTEXT.md)。
工作线程崩溃不会使流水线搁浅。运行 `RUNNING` 时,工作线程续签 `heartbeat_at` 租约;如果租约过期,则协调器通过其动作的幂等姿态恢复运行,因此作业的 `Job.state` 结算,其配额释放,其循环恢复而不是永远卡在卡住的 `RUNNING` 行。对于外部副作用,没有真正的 exactly-once,因此 `email_send` 是 **effectively-once**:运行派生的幂等键(`{action}:{run_id}`)和写前 `send_intents` 记录使重新传递或重试的发送成为无操作,发出原始消息 ID 而不是第二个电子邮件。至少一次交付加上幂等消费者是诚实的端到端保证([ADR-069](docs/adr/ADR-069-running-orphan-recovery-and-heartbeat-lease.md),[ADR-070](docs/adr/ADR-070-email-send-effectively-once-posture.md))。
## 安全模型
公共部署([ADR-049](docs/adr/ADR-049-public-product-multi-tenant-oauth-delegation.md))构建在两层之上。
**第 1 层,您是谁。** WorkOS AuthKit 是 OAuth 2.1 授权服务器;该服务器仅是资源服务器。每个 `/mcp` 请求都需要有效的 WorkOS 承载令牌,与 JWKS 令牌验证 audience 和 resource binding(RFC 8707)以阻止 confused-deputy 攻击。服务器发布受保护的资源元数据(RFC 9728),并以 `401` 和 `WWW-Authenticate` 质询回答未经身份验证的请求,这是 MCP 客户端发现登录流程的方式。`user_id` 是验证令牌的主题,因此租户隔离是结构化的。
**第 2 层,您可以操作什么。** 每个用户通过 `/connections` 上的连接按钮连接自己的 GitHub、Slack 或 Google 账户。生成的 OAuth 令牌使用 AWS KMS 信封加密(每写入一个新鲜的数据密钥;密钥材料永远不会离开 KMS)进行静态加密,并在到期时自动刷新。系统永远不会存储公共用户的原始、长期密钥。
凭证来自两个不重叠的轨道([ADR-050](docs/adr/ADR-050-dual-credential-model-oauth-vs-operator-env.md)):公共用户使用自己的 OAuth 连接,而服务器自己的操作使用 `${VAR}` 环境变量替换。读取这些服务器端密钥或可以访问任意 URL 的操作(`http_call`、`calendar_digest_ics`)仅限部署者拒绝,对于其他人,在 `task.create` 处拒绝([ADR-051](docs/adr/ADR-051-action-surface-tiering-public-oauth-vs-operator-only.md))。
**第 3 层,您的输入可以访问的内容。** `task.create` 参数中的所有内容都是不受信任的,并且所有权在它流经的两个平面上都被强制执行。在控制平面上,链触发器(`trigger_on_job_id`)只能指向您自己的作业;在数据平面上,`from_run_id` - 将一个作业的结果馈送到下一个处理程序 - 限定为您的作业,因此另一个租户的作业 ID 解析为 *not found* 而不是泄漏其结果。成本上限的 LLM 操作没有自由格式的提示:它们的 `language` / `focus` 旋钮是长度有界且不允许换行符的,因此它们不能重写固定的系统提示,并且公共操作永远不会执行 `${VAR}` 秘密替换([ADR-071](docs/adr/ADR-071-input-abuse-and-prompt-injection-hardening.md))。
单个 $5 核心通过分层限制保持安全([ADR-055](docs/adr/ADR-055-public-abuse-cost-containment-posture.md),[ADR-057](docs/adr/ADR-057-overload-protection-load-shedding-concurrency.md)):每个用户创建速率(100/天,5/分钟),活跃循环作业上限(5)和总作业上限(50),当盒子不健康时边缘负载脱落,飞行中并发上限,以及队列深时 `429` 反压。每个限制都是 env-configurable。结构化 JSON 日志发送到 Better Stack,并带有每个用户和每个运行的相关性,并且永远不会记录令牌([ADR-056](docs/adr/ADR-056-observability-structured-json-logging-better-stack.md))。
## 部署
实时实例在东京的 AWS Lightsail 盒子上运行,月度费用约为 5 美元,KMS 费用约为 1 美元。Caddy 终止 TLS 并自动获取证书,然后反向代理到应用程序。Postgres 在容器中运行,每晚备份到 Cloudflare R2。`main` 上的每个推送通过 CI 自动部署,并在 SSH 上部署,`/healthz` 报告正在运行的提交 SHA 以确认内容是什么。
公共状态页面 [status.paynepew.dev](https://status.paynepew.dev) 跟踪正常运行时间。Better Stack 探针每 3 分钟从盒子外部探测 `/healthz`,因此监视器在盒子宕机时存活,并通过电子邮件和 Slack 发送警报;页面显示 30/60/90 天的正常运行时间历史记录。服务器还公开 `/healthz/shed`,Caddy 用作健康检查,在盒子不健康时减少负载([ADR-031](docs/adr/ADR-031-monitoring-better-stack-over-uptimerobot.md),[ADR-057](docs/adr/ADR-057-overload-protection-load-shedding-concurrency.md))。
## 本地开发
主机侧测试循环,无需完整堆栈:
```bash
uv sync
cp .env.example .env
docker compose up -d postgres elasticmq # 仅 Postgres 和队列
uv run alembic upgrade head
uv run pytest -m "not integration" && uv run pytest -m integration
uv run ruff check . && uv run ruff format --check .
```
如果您有一个生产风格的 `.env` 文件,请在运行单元测试之前将其移开:KMS 和 WorkOS 分支会改变行为并产生错误失败。完整点击验证:[docs/PRODUCTION-VERIFICATION.md](docs/PRODUCTION-VERIFICATION.md)。
## 设计决策
决策记录为 ADR 在 [docs/adr/](docs/adr/) 下,领域语言在 [CONTEXT.md](CONTEXT.md) 中。值得阅读的决策:
| 主题 | ADR |
|---|---|
| 传输和数据模型 | [006](docs/adr/ADR-006-mcp-transport-dual-stdio-http.md) 双 stdio/HTTP, [009](docs/adr/ADR-009-database-schema-outbox.md) outbox 模式, [007](docs/adr/ADR-007-watcher-ha-skip-locked.md) SKIP LOCKED 监视器 |
| 操作和链接 | [013](docs/adr/ADR-013-action-catalog-typed-registry.md) 类型注册, [033](docs/adr/ADR-|handler-data-flow-via-job-run-result.md) 链接数据平面, [067](docs/adr/ADR-067-continuation-chaining-replaces-pre-armed-waiting-runs.md) 延续链接, [068](docs/adr/ADR-068-job-state-machine-replaces-active-boolean.md) 作业状态生命周期 |
| 公共身份验证和密钥 | [049](docs/adr/ADR-049-public-product-multi-tenant-oauth-delegation.md) 多租户部署, [053](docs/adr/ADR-053-layer1-authorization-server-workos-authkit.md) WorkOS, [054](docs/adr/ADR-054-layer2-token-storage-aws-kms-envelope-encryption.md) KMS 密钥, [050](docs/adr/ADR-050-dual-credential-model-oauth-vs-operator-env.md) / [051](docs/adr/ADR-051-action-surface-tiering-public-oauth-vs-operator-only.md) 凭证分层, [071](docs/adr/ADR-071-input-abuse-and-prompt-injection-hardening.md) 输入滥用 / 提示注入强化 |
| 成本和弹性 | [055](docs/adr/ADR-055-public-abuse-cost-containment-posture.md) 配额, [057](docs/adr/ADR-057-overload-protection-load-shedding-concurrency.md) 过载保护, [056](docs/adr/ADR-056-observability-structured-json-logging-better-stack.md) 结构化日志, [031](docs/adr/ADR-031-monitoring-better-stack-over-uptimerobot.md) 外部监控 |
| 耐久性和交付 | [069](docs/adr/ADR-069-running-orphan-recovery-and-heartbeat-lease.md) RUNNING-orphan 恢复 + 心跳租约, [070](docs/adr/ADR-070-email-send-effectively-once-posture.md) 实际上一次电子邮件 |
## 状态
托管实例实时运行,多租户:OAuth 登录,每个用户连接,上述六种操作,循环调度,链接流水线,崩溃耐用性执行(心跳租约 + 协调器孤儿恢复)和实际上一次电子邮件。推迟到后续版本:扇入(一个作业读取多个上游),更高级别的计划抽象,以及以自己的 MCP 客户端运行工作线程([ADR-038](docs/adr/ADR-038-mcp-call-as-future-direction.md) 到 [ADR-040](docs/adr/ADR-040-predicate-based-chain-as-future-direction.md))。问题和讨论:[github.com/PaynePew/task_scheduler_mcp/issues](https://github.com/PaynePew/task_scheduler_mcp/issues)。
MCP 配置
以下是此 MCP Server 的配置信息,可直接复制到 Cursor 或其他 MCP 客户端中使用。
mcp.json
连接信息
猜你喜欢
暂无推荐服务器