schedule-manager
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
schedule-manager
面向 LLM 工具调用的多用户日程管理系统,提供远程 MCP Server(Streamable HTTP)和 CLI 两种接口。 每个请求携带 Bearer 令牌,所有查询自动限定在该令牌对应的用户范围内。
技术栈
Python 3.12+
rhosocial-activerecord — ActiveRecord 模式 ORM
rhosocial-activerecord-postgres — PostgreSQL 异步后端(psycopg3)
MCP SDK v2(
MCPServer)— Streamable HTTP 传输 + OAuth 2.1 资源服务器python-dateutil — RFC 5545 RRULE 解析
Pydantic — 模型校验
Related MCP server: AI Calendar Assistant
核心设计:身份如何隔离
HTTP 请求
BearerAuthBackend → TokenTableVerifier → User.resolve_token() ← 哈希查库 + 校验,每次请求一次
→ 写 auth_context_var(SDK 挂载的 AuthContextMiddleware)
→ 工具执行
→ Schedule.query() → current_user_id() → WHERE user_id = <已验证> ← 零解析三条强制点,覆盖全部读写路径:
路径 | 机制 |
读 |
|
写 |
|
身份来源 |
|
为什么接缝选在 __init__ 而不是各个终结方法:Model.query() 就是
return cls.__query_class__(cls)(base/query_mixin.py:135),谓词在构造期
就位,之后 all / one / count / exists / aggregate / sum_ /
update_all / delete_all 全部自动继承。
若改为逐个覆写终结方法则需要 5~6 处,因为执行路径并不统一——
count 与数值聚合走 await self.aggregate()(aggregate.py:450),
而 all() 走 self.to_sql()。漏掉任一处不报错,只会静默返回别人的数据。
跨用户访问表现为 NOT_FOUND 而非 FORBIDDEN,避免通过 id 探测他人数据是否存在。
安装
git clone https://github.com/vistart/schedule-manager.git
cd schedule-manager
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"本地编辑模式安装 ORM 依赖(改动即时生效)、接入 opencode 的完整步骤见 接入 opencode。
配置
cp .env.example .env变量 | 说明 |
| 数据库连接 |
| 本地调用者的 Bearer 令牌(明文仅此一份,库里只存哈希) |
| 令牌文件路径,优先于 |
| 远程服务监听地址,默认 |
| 对外可达 URL,即 OAuth 资源标识;非本地必须 HTTPS |
| DNS rebinding 防护白名单 |
SCHEDULE_TOKEN_FILE 更安全:导出到环境变量的值在 /proc/<pid>/environ 里可见,
文件可以只让本人读。
初始化与开户
schedule-manager-setup-db # 建表 + 建索引
schedule-manager user open --username alice --label laptopuser open 会打印一次明文令牌。之后库里只保留 SHA-256 摘要,无法恢复。
所有语句都是
IF NOT EXISTS。本项目不做数据库迁移 —— DDL 即 schema, 携带旧结构的库不会被就地升级。
数据模型
所有表都带 sm_ 前缀:库可能与其它服务共用,users / schedules 这种名字别人也会用。
表名只在 model 上声明一次(__table_name__),schema/ 用 Model.table_name() 读回来而不是
再抄一份字符串,索引名也由表名派生——PostgreSQL 的索引全在一个命名空间里,索引名撞车会
让整个 create_all 挂掉。
表 | 关键列 | 说明 |
|
| 账户。销户是停用不是删除,历史归属必须可追溯 |
|
|
|
| 复合主键 | 一行一个 scope; |
|
| → |
ON DELETE 语义按关系性质区分:sm_schedules / sm_api_tokens 指向 sm_users 用
RESTRICT(承载独立数据,不能连带清空);sm_api_token_scopes 指向 sm_api_tokens
用 CASCADE(子集合,令牌没了就是垃圾)。
scope 是封闭词表:schedules:read、schedules:write。SDK 用
AuthSettings.required_scopes 生成 PRM 的 scopes_supported,库里出现词表外的
scope 会让两者漂移。无 scope 行的令牌 = 任何工具都不可用(fail-closed)。
令牌设计
生成: sm_<32 bytes urlsafe> 仅签发时出现一次
存库: sha256(token).hexdigest() CHAR(64) UNIQUE存哈希不存明文。这是公共服务,库被读就等于全部凭据泄露。
用 SHA-256 而非 bcrypt/argon2:令牌是 256 位高熵随机串,不是低熵口令, 没有抗暴力破解需求,无需故意拖慢。查找走唯一索引,连常量时间比较都不需要。
不透明而非 JWT:天然没有
aud混淆(为别的服务签发的令牌调不了本服务), 代价是无法自包含身份,换来的是无需外部 IdP、吊销即时生效。
MCP Server
启动
schedule-manager-mcp监听 SCHEDULE_BIND_HOST:SCHEDULE_BIND_PORT,MCP 端点在 /mcp。
DNS rebinding 防护只在绑定地址是 loopback 时自动开启
(mcpserver/server.py:1144-1150)。部署到真实域名上必须显式设置
SCHEDULE_ALLOWED_HOSTS / SCHEDULE_ALLOWED_ORIGINS,否则端点处于无保护状态。
认证与授权
情况 | 响应 |
无令牌 / 令牌不存在 |
|
令牌已吊销 / 过期 |
|
用户已停用 |
|
scope 不足 |
|
访问他人日程 |
|
scope 按单个工具判定,而不是全局:
工具 | 所需 scope |
| 无 |
|
|
|
|
只读令牌不能写,但能读;whoami 不需要任何 scope,所以零授权的令牌也知道
自己是谁。
工具列表
工具 | 说明 |
| 当前用户 id / username / 令牌的 scopes / 过期时间 |
| 创建日程 |
| 按 ID 查询 |
| 更新(只改传入的字段) |
| 软删除 |
| 分页列表(筛选、排序) |
| 标记完成 |
| 关键词搜索 |
所有工具参数均为命名参数,返回 JSON 字典。
没有任何工具可以跨用户枚举。 User / ApiToken 出于管理需要不是用户隔离
模型,把它们做成工具等于开放全量用户转储。
接入 opencode
配置写在全局 ~/.config/opencode/opencode.json(不在仓库里):
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"schedule-manager": {
"type": "remote",
"url": "http://127.0.0.1:18000/mcp",
"enabled": true,
"oauth": false,
"timeout": 60000,
"headers": { "Authorization": "Bearer sm_xxx" }
}
}
}oauth: false关闭 opencode 的自动 OAuth 探测——服务端用的是静态令牌。url里的 host:port 必须和容器启动时的SCHEDULE_PUBLIC_URL一致,否则 Host 头 对不上,被 DNS-rebinding 防护挡成 421。不要把令牌放进
command的 args,那会出现在ps aux里。
放全局而不是项目 opencode.json 有两个原因。一是覆盖面:opencode 的配置优先级是
「远程 < 全局 < OPENCODE_CONFIG < 项目 < .opencode/ < OPENCODE_CONFIG_CONTENT」,
项目里的 opencode.json 会压过全局——同一个 server 名两处都写,在项目目录里生效的永远
是项目那份,全局那条等于白写。二是密钥:项目配置是要入库的,而全局配置不是。
所以令牌可以直接写在全局配置里,不必依赖环境变量。代价是这个文件从此是一份明文凭据,
安全性取决于该文件的权限——它等价于把令牌放在一个 0600 的文件里,而不是放在
SCHEDULE_TOKEN 环境变量里(后者任何能读 /proc/<pid>/environ 的进程都能看到)。
想让它离开明文,opencode 提供了 {file:...},语义等同 SCHEDULE_TOKEN_FILE:
"headers": { "Authorization": "Bearer {file:~/.config/schedule-manager/token}" }如果这份配置要入库,就必须用 {env:SCHEDULE_TOKEN} 而不是字面量。 写死
"Bearer sm_alice",同事 clone 仓库后他的 opencode 就成了 alice——那个形式本身就是防
"入库配置冒充身份"的手段。本仓库没有项目级 opencode.json,正是因为这个取舍。
验证:
opencode mcp list # 期望 schedule-manager connected
opencode mcp debug schedule-manager # 连不上时看 HTTP 与鉴权细节config 只在启动时加载,改完必须重启 opencode。
临时改指向(不改任何文件)
OPENCODE_CONFIG_CONTENT 的优先级高于所有配置文件,可以在启动时覆盖。注意两点:
覆盖条目必须写全——配置源之间是逐键合并的,但每个配置源独立校验,只写
headers 会被拒绝(Missing key mcp.schedule-manager.enabled),不会替你从别处补齐;
timeout 之类没冲突的键则会从下层配置继承。
export OPENCODE_CONFIG_CONTENT='{"mcp":{"schedule-manager":{
"type":"remote","url":"http://127.0.0.1:8000/mcp","enabled":true,
"oauth":false,"timeout":60000,
"headers":{"Authorization":"Bearer {env:SCHEDULE_TOKEN}"}}}}'
opencode健康检查
GET /healthz 无需令牌,返回 200 {"status":"ok"} 或 503 {"status":"unavailable"}。
它会真的 SELECT 1 探一次数据库——只报告"进程活着"的探针,会把一个连不上库
的容器报成健康。lifespan 还没建好连接池时(503)也是正常状态。
另一个无需令牌的端点是 /.well-known/oauth-protected-resource(OAuth 资源元数据),
但它不碰数据库,不能当健康检查用。
容器部署
镜像用多阶段构建:builder 里 pip wheel 产出全部 wheel,运行阶段
--no-index 离线安装,因此被测过的那份依赖集合就是发布的那份。进程以非 root
运行,CMD 用 exec 形式,SIGTERM 才能到 uvicorn 并触发 lifespan 里的
close_pool。
bash docker/build-wheels.sh # 把两个 ORM wheel 放进 wheels/(首次必做)
docker compose up -d --build
docker compose run --rm app schedule-manager user open --username alicebuild-wheels.sh 存在的原因:PyPI 上的 rhosocial-activerecord 是 dev29、
rhosocial-activerecord-postgres 是 dev16,而 release 分支已经是 dev30 /
dev17,本地 venv 装的就是这两个分支。直接 pip install . 的话镜像跑的是
落后一版的 ORM。脚本会先把源码拷到 /tmp 再构建——两个仓库在 Windows 盘挂载上,
setuptools 扫包要几分钟,在原生文件系统上是几秒。
依赖下载走阿里云镜像(ARG PIP_INDEX_URL,可用 --build-arg 覆盖)。注意改任何
一行源码都会让这一层失效、下次构建重下全部依赖;把依赖层拆出来能解决,代价是一个
需要 || true 的空包桩,可能静默产出两个同版本 wheel,所以没做。
镜像里已经写死了三件事:
ENV | 值 | 原因 |
|
| 默认是 |
|
| 多副本同时启动会抢 DDL 锁,schema 交给部署步骤 |
| 打 |
|
必须运行时传入的:
ENV | 说明 |
|
|
| 客户端实际访问的 HTTPS 地址,也是令牌绑定的 resource id |
| 默认允许列表由 |
连接数 = 副本数 × workers × pool_max,总量控制在 32 以内;调 worker 前先看 这个乘积。
生产部署的差别只有两处:前面加一层终止 TLS 的反代(uvicorn 这边没配证书),以及
把 ports 换成内网地址或直接删掉——令牌在 header 里,明文过一次网络就等于泄漏。
CLI
export SCHEDULE_TOKEN=sm_xxx
schedule-manager whoami
schedule-manager create --title '团队站会' --priority 3
schedule-manager list --status pending --sort-by priority
schedule-manager search --keyword deadlineCLI 与远程 MCP 接受同一种凭据,所以一个令牌两边都能用。
输出格式
默认 JSON 信封,--human 输出人类可读文本,--describe 输出机器可读 schema。
{"status": "ok", "data": {...}}
{"status": "error", "error": {"code": "NOT_FOUND", "message": "..."}}全局选项
选项 | 说明 |
| Bearer 令牌,回落到 |
| 人类可读文本 |
| JSON Schema 并退出 |
| 帮助 |
业务命令
whoami / create / get / update / delete / complete / list / search
参数同旧版,另加 --dry-run(create / update / delete / complete)。
管理命令
不暴露为 MCP 工具,只走 CLI。
命令 | 说明 |
| 开户并签发首个令牌(明文打印一次) |
| 停用账户并吊销其全部令牌;日程保留但不可达 |
| 追加令牌 |
| 吊销令牌 |
| 列出令牌,只显示摘要前 8 位,永不显示令牌本身 |
退出码
退出码 | 含义 |
0 | 成功 |
1 | 一般错误(含配置问题) |
2 | 参数错误 |
20 | 资源不存在(含他人的日程) |
40 | 校验错误 |
41 | 未认证(缺令牌 / 不存在 / 已吊销 / 已过期 / 账户已停用) |
42 | 禁止 |
开发
pip install -e ".[test,dev]"测试
pytest tests/ -v文件 | 需要数据库 | 覆盖 |
| 否 | 身份上下文、生成 SQL、DDL、校验 |
| 是 | 账户、凭据、CRUD、查询、隔离矩阵 |
| 是 | 真实子进程:信封、退出码、跨用户拒绝 |
数据库不可达时后两者跳过而非失败,test_offline.py 任何环境都能跑。
隔离契约优先用生成 SQL 断言(无需连库,直接检查契约而非观察结果):
with as_user(7):
assert '"user_id" = %s' in Schedule.query().to_sql()[0]跨用户用例必须断言两半:调用失败且目标行未被改动。
test_cli.py 较慢(每例约 14s),因为每次子进程调用都要付冷启动导入的代价。
项目结构
src/schedule_manager/
├── config.py # DB / 服务端 / 令牌配置
├── db.py # 共享 backend 装配(见下)
├── errors.py # 三个领域异常
├── identity.py # current_user_id(),身份唯一出口
├── auth.py # SDK TokenVerifier 薄适配器
├── models/ # base.py / user.py / token.py / schedule.py
├── schema/ # _column.py / users.py / tokens.py / schedules.py
├── mcp_server.py # Streamable HTTP
├── cli.py # CLI 入口
└── setup_db.py # 建表
Dockerfile # 多阶段构建,运行阶段离线装 wheel
compose.yaml # postgres + 一次性建表任务 + 服务
docker/
└── build-wheels.sh # 把本地 ORM release 分支打成 wheels/ 里的 wheel
.dockerignore # 关键是把 .env 挡在构建上下文外
wheels/ # build-wheels.sh 的产物,被 .gitignore 忽略两个容易踩的坑
Model.configure() 是按类生效的。 每调用一次就新建一个 backend 实例并
独立建连接。配置四个模型 = 四条连接,于是通过某个模型开启的事务覆盖不到
通过另一个模型发出的语句——它们在不同会话上,互相看不见未提交的数据。
一律走 db.connect():它配置一个类,再把 backend 分发给其余模型。
嵌套事务会提前提交。 Postgres backend 只有单连接,事务状态是实例级的,
内层 backend.transaction() 会把外层的工作先提交掉,外层再提交一次。
models/user.py 里的 transaction() 辅助函数在已有事务时直接复用,而不是新开。
许可证
MIT
Related MCP Connectors
AI-native scheduling: check availability, book meetings, cancel and reschedule via MCP
Official Akiflow MCP for tasks, calendars, scheduling, time slots, and meeting transcripts.
Manage Superlist tasks and lists in plain language from any MCP-compatible AI agent.
Scheduling infrastructure for AI agents across Google and Microsoft calendars.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables creation and management of scheduled tasks with interval, cron, or one-time triggers. Persists tasks in SQLite and supports MCP sampling to automatically invoke AI agents when schedules trigger.6 npm3MIT
- AlicenseNot gradedqualityDmaintenanceProvides a set of MCP tools to manage Google Calendar events via natural language, allowing creation, querying, updating, and deletion of events through a local LLM (Ollama) and MCP client.6MIT
- AlicenseNot gradedqualityDmaintenanceEnables LLMs to manage Google Calendar events and Google Tasks through a set of MCP tools.14 npm2MIT
- AlicenseNot gradedqualityDmaintenanceExposes Cal.com scheduling tools to AI agents via MCP, enabling listing event types, checking availability, and managing bookings (create, cancel, reschedule).295 npmMIT