关于 Slotlock
Slotlock 是供 AI Agent 和开发者使用的日历服务,可为房间、车辆或人员等资源安排预订。PostgreSQL 负责阻止冲突预订;查询可区分已确认的空闲时段与尚未同步、无法确认的时段。
主要功能
- 01按每周规则设置可用时间
- 02查询忙闲和下一个空档
- 03创建和更新资源预订
- 04通过 PostgreSQL 防止时间冲突
- 05支持幂等写入和版本校验
- 06按权限范围管理 API 密钥
适用场景
- 01查找可用车辆或房间
- 02避免资源预订时间重叠
- 03确认未同步日历时段是否确实空闲
MCP 相关
通过 `/mcp` 提供基于 Streamable HTTP 的 MCP 工具,并使用 Bearer token 认证。Agent 可按密钥权限查询可用时间,并创建、更新或删除预订。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
docker run -i --rm -e DATABASE_URL -e SLOTLOCK_AUTH_TOKEN -e SLOTLOCK_CONFIRMATION_SECRET -e SLOTLOCK_METRICS_TOKEN -e PORT -e SLOTLOCK_MIGRATE_DATABASE_URL -e SLOTLOCK_CONFIRM_WRITES -e SLOTLOCK_AVAILABILITY -e SLOTLOCK_REST_API -e SLOTLOCK_RETENTION_DAYS -e SLOTLOCK_SHARED_LIMITS -e SLOTLOCK_WEBHOOKS -e SLOTLOCK_TENANT ghcr.io/pilotaai/slotlock:0.6.0DATABASE_URL密钥postgres:// URL of a PostgreSQL 16 or newer database the container can reach, as a role that is not a superuser and has no BYPASSRLS: serve refuses one, since row-level security would not confine it. Without SLOTLOCK_MIGRATE_DATABASE_URL this role also applies the schema, so it must be able to create it.SLOTLOCK_AUTH_TOKEN密钥Optional bearer token for SLOTLOCK_TENANT, with read and write (never admin): at least 32 random characters (openssl rand -hex 32). Leave it unset to accept only API keys (slotlock key create).SLOTLOCK_CONFIRMATION_SECRET密钥Signs pending write confirmations: at least 32 random characters, different from the token. Required unless SLOTLOCK_CONFIRM_WRITES is none.SLOTLOCK_METRICS_TOKEN密钥Serves Prometheus metrics at /metrics to this bearer token: at least 32 random characters, different from every other secret. Off without it.PORTPort the server listens on inside the container.SLOTLOCK_MIGRATE_DATABASE_URL密钥postgres:// URL of the schema owner. When set, migration runs as this role and grants the DATABASE_URL role only what it needs at runtime.SLOTLOCK_CONFIRM_WRITESWrites that wait for a person to confirm them: none, all, or a comma-separated list of slotlock_create_event, slotlock_book_together, slotlock_book_pool, slotlock_update_event, slotlock_delete_event. Clients without MCP 2026-07-28 form elicitation have guarded writes refused.SLOTLOCK_AVAILABILITYJSON array of weekly bookable-hours rules, e.g. [{"rrule":"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR","startMinutes":540,"durationMinutes":480}]. Without rules no slot is ever offered.SLOTLOCK_REST_APIon: also serve the tools as a REST API under /v1, and the application API (resources, bookable hours, holds, reservations, coverage, maintenance) for admin keys, described by an OpenAPI 3.1 document at /openapi.json. Writes that wait for confirmation answer 428 there. off: MCP and A2A only.SLOTLOCK_RETENTION_DAYSDays of history kept: the change feed (who made each change included) and how far back a cursor reaches, and ended waitlist entries; retried writes are answered for at least 30 days whatever it is. 1-3650; default 30.SLOTLOCK_SHARED_LIMITSon: tenant request budgets (SLOTLOCK_LIMITS' tenant_* and app_*) are shared by every server of a deployment through PostgreSQL, so N servers allow a tenant its rate once, not N times. off: each server keeps its own.SLOTLOCK_WEBHOOKSon: deliver each tenant's changes to its webhooks (POST /v1/webhooks), signed as Standard Webhooks; never to a private network address. private: those too, for testing. off: keep the change feed (GET /v1/changes), send nothing.SLOTLOCK_TENANTThe tenant SLOTLOCK_AUTH_TOKEN acts in; API keys act in their own tenant.本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
ghcr.io/pilotaai/slotlock:0.6.0Streamable HTTP工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
Slotlock
Slotlock 是面向 AI Agent 的日历。它可以预订车辆、房间、机器、人员或任何其他可预订的资源;根据每周可用时间规则回答“什么时候有空?”,并让 PostgreSQL 拒绝重复预订,无论有多少写入者同时竞争。Agent 可通过 MCP 或 A2A 调用它;你的代码可将它用作 TypeScript 库。
为何它专为 Agent 构建
- 日历归资源所有,而非用户所有;租户信息仅来自经过身份验证的调用者。
- 时间区间采用左闭右开形式
[start, end),因此首尾相接的预订不会冲突。 - PostgreSQL 排斥约束负责判定重叠;冲突会作为数据返回,而非错误。
- 写入具有幂等性,并要求提供预期版本号。
- 空闲/忙碌状态会说明是否确定:尚未同步的日历表示时间尚未得到证实,而非空闲。
- 工具名称、架构和错误可用于 Claude、OpenAI 及所有 MCP 宿主。
安装
npm install slotlock postgres
需要 Node.js 22.12+、启用了 btree_gist 的 PostgreSQL 16(如缺少则会为你创建),以及作为对等依赖的 postgres ^3.4.5。随附 TypeScript 声明。
使用 Docker 自托管
在克隆的仓库中运行:
cp .env.example .env # set the four empty secrets: openssl rand -hex 32
docker compose up --build --detach
docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/London
| URL | 说明 |
|---|---|
http://localhost:8080/mcp | MCP,使用 Authorization: Bearer <SLOTLOCK_AUTH_TOKEN> |
http://localhost:8080/a2a | A2A 1.0,使用相同令牌;卡片位于 /.well-known/agent-card.json |
http://localhost:8080/v1/… | REST:这些工具,以及用于管理密钥的应用 API;文档见 /openapi.json |
http://localhost:8080/healthz | 就绪状态,包括数据库状态 |
- 服务以一个不拥有任何对象且无法绕过行级安全策略的角色运行;一次性
migrate服务会以所有者身份应用架构。 SLOTLOCK_AVAILABILITY用于设置默认的可预订时段。资源可以设置自己的时段(使用slotlock hours set或通过控制面板);两者都未设置时,不会提供任何时段。SLOTLOCK_CONFIRM_WRITES=all(默认值)会让每次写入都等待人员确认。- 如需在本机以外提供服务,请在前面配置 TLS 代理,并设置
SLOTLOCK_PUBLIC_URL。
slotlock hours set '[{"rrule":"FREQ=WEEKLY;BYDAY=SA","startMinutes":600,"durationMinutes":240}]' --resource vehicle-42
slotlock hours clear --all # back to SLOTLOCK_AVAILABILITY
slotlock --help 会列出所有设置。
不使用 Docker
使用 Node.js 22.12+ 和 PostgreSQL 16 时,先运行
npm install slotlock postgres,然后将每条命令作为 npx slotlock 运行;或者从仓库克隆并构建一次后,以 node dist/cli.js 运行(克隆后不会将 slotlock 添加到你的 PATH)。像 Compose 一样使用两个角色:
服务以一个不拥有任何对象且无法绕过行级安全策略的角色连接;migrate
则以数据库所有者身份连接,并向该角色授予所需权限。serve 不允许以超级用户或
BYPASSRLS 角色启动,因为行级安全策略无法对其进行限制。
npm ci && npm run build
# Once, as a superuser:
# CREATE ROLE slotlock_app LOGIN PASSWORD '<app password>' NOSUPERUSER NOBYPASSRLS;
export SLOTLOCK_MIGRATE_DATABASE_URL='postgresql://<owner>:<owner password>@localhost:5432/slotlock'
export DATABASE_URL='postgresql://slotlock_app:<app password>@localhost:5432/slotlock'
export SLOTLOCK_AUTH_TOKEN="$(openssl rand -hex 32)"
export SLOTLOCK_CONFIRMATION_SECRET="$(openssl rand -hex 32)"
node dist/cli.js migrate
node dist/cli.js resource add vehicle-42 --timezone Europe/London
node dist/cli.js serve
本 README 中的 slotlock … 命令与 node dist/cli.js … 的运行方式相同(使用 Compose 时为
docker compose exec slotlock slotlock …)。
API 密钥
为每个 Agent 单独创建密钥,而不是共享服务令牌:
slotlock key create "Booking agent"
slotlock key create "Availability bot" --scope read --expires-in-days 90
slotlock key create "Booking site" --scope read,write,admin
slotlock key list
slotlock key rotate <id>
slotlock key revoke <id>
- 密钥(
slk_…)只会显示一次,且仅以 SHA-256 形式存储。通过Authorization: Bearer发送。 - 密钥仅能在创建它的租户(
SLOTLOCK_TENANT)中操作,因此一台服务器可为多个租户提供服务。 read包含仅用于查看的五个工具;write可执行创建、更新和删除;admin可调用应用 API,Agent 无需使用该权限。默认权限为 read 和 write。rotate会保留密钥的 ID、权限范围和预订;revoke会在密钥下一次请求时将其停用。- 每个租户最多可持有 100 个有效密钥。末尾六个字符是校验和,密钥扫描器可离线验证。
仪表板
服务器还可以提供一个仪表板,让用户通过 GitHub 登录并管理自己的密钥和资源,这样你就可以为每个人提供 API 密钥,而不必替他们运行 slotlock key create:
- 在 GitHub 上,打开 Settings、Developer settings、OAuth apps、New OAuth App,并将
“Authorization callback URL”设为
<SLOTLOCK_PUBLIC_URL>/dashboard/callback。然后在应用页面上生成 客户端密钥。 - 根据应用设置
SLOTLOCK_GITHUB_CLIENT_ID和SLOTLOCK_GITHUB_CLIENT_SECRET, 将SLOTLOCK_SESSION_SECRET设为由 32 个或更多随机字符组成的值(openssl rand -hex 32),并将SLOTLOCK_DASHBOARD_USERS设为允许登录的 GitHub 用户 ID(gh api users/<login> --jq .id), 或设为*,允许所有 GitHub 账户登录。 - 运行
slotlock serve并打开<SLOTLOCK_PUBLIC_URL>/dashboard。使用 Compose 时,在.env中设置这四项(留空则使用默认值,即关闭仪表板),然后运行docker compose up --detach。
- 每个人都有自己的租户
github:<user id>:其中的密钥、资源和预订都只属于该用户。使用slotlock key create创建的密钥则在SLOTLOCK_TENANT中生效。 - 登录时不会向 GitHub 请求任何权限范围,会使用
state和 PKCE;除用户 ID 和登录名外,不会保留任何 GitHub 信息:GitHub 令牌只使用一次,随后即丢弃。 - 会话是经过签名的 HttpOnly Cookie,有效期为 12 小时。每次请求(包括仪表板和 API 请求)都会检查允许名单:移除某个 ID 并重启服务器后,该用户会被登出,其租户中的所有密钥也会立即失效。
- 每个表单都包含一个绑定到会话的令牌,且必须来自服务器自身的源;页面不会运行内联脚本,并受 Content-Security-Policy 限制,只允许加载自身文件。
- 为仪表板使用独立的源(
https://slotlock.example.com,而不是与其他应用共用路径):Cookie 的作用范围涵盖整个主机,因此同一源上的任何其他应用都可能读取它们。 - 已完成的登录(每个回调只能使用一次)、已登出的会话(复制的 Cookie 无法继续访问)和已提交的表单(重新加载不会重复创建或轮换密钥)都会记录在 Postgres 中,因此任何共用该数据库的服务器都能识别它们,包括负载均衡器后面的服务器。每条记录都是一个 SHA-256 摘要,并保留到其 Cookie 或表单过期(最长 12 小时);表单记录还包含提交该表单的 GitHub 用户 ID 的摘要。服务器角色只能通过两个 SECURITY DEFINER 函数访问这些记录;在
migrate授予相应权限之前,serve不会启动仪表板。 - 数据库时钟决定登录或会话何时过期,因此即使服务器时钟与数据库或彼此之间存在偏差,也无法通过重放请求或使用已登出的会话绕过检查。无论如何都要保持时钟同步:如果服务器时间比数据库快 12 小时以上,就无法让任何人登录。
- 每个表单都包含一个绑定到其会话的单次使用值。每个人最多可同时记录 1,000 个表单(即 12 小时内的表单);超过此数量后,仪表板会返回 429,直到最早的记录过期。登出始终可用。
- 每个人最多可添加 100 个资源。数据库会强制执行此上限,因此无论有多少台服务器同时处理添加请求,数量都会停在 100;用户已有的资源仍可更改时区。
- 每个资源都会使用服务器可预订的时段(
SLOTLOCK_AVAILABILITY),直到其所有者在 hours 页面上为该资源设置专属时段:每天最多两个时间段,可跨夜或全天开放,也可关闭。提交一个表单即可同时为所有资源设置时段。
Cloudflare Workers 上的仪表板
同一个仪表板可以作为独立源站上的 Cloudflare Worker 运行(workers/dashboard),
同时 Agent 继续使用服务。它会显示服务的 MCP 和 A2A URL(SLOTLOCK_SERVICE_URL),
并通过 Hyperdrive 访问服务所使用的数据库。
workers/dashboard/wrangler.jsonc 是一个示例:将它复制到自己的部署中,并替换其中标记为需自行填写的内容。此仓库的 Dashboard 工作流只会检查 Worker 是否能打包。请使用自己的流水线和 Cloudflare 令牌进行部署:能部署 Workers 的令牌可以替换此令牌并读取其会话密钥,从而以任意租户的身份登录。
- 为该服务角色创建一个 Hyperdrive 配置,并关闭缓存:仪表板会读取刚记录的登录信息或表单内容,而缓存的登出检查会使复制的会话继续有效。每次部署前都要检查配置(
wrangler hyperdrive get <id>)。对于位于私有网络中的数据库,请通过 Workers VPC 连接到可访问该数据库的 Cloudflare Tunnel。将 VPC 服务指向仅提供 Slotlock 数据库服务的侦听器,例如配置了 TLS 且仅允许访问该数据库和角色的 PgBouncer,而不要直接指向整个 Postgres:cd workers/dashboard && npm ci npx wrangler vpc service create slotlock-postgres --type tcp --tunnel-id <tunnel id> \ --ipv4 <listener host> --tcp-port <listener port> --app-protocol postgresql read -rs SLOTLOCK_DB_PASSWORD # not on the command line, so not in your shell history npx wrangler hyperdrive create slotlock-dashboard --service-id <vpc service id> \ --database <database> --user <server role> --password "$SLOTLOCK_DB_PASSWORD" --caching-disabled - 在配置副本中设置:
- 配置的 id;
- 你的主机名(
routes); SLOTLOCK_PUBLIC_URL、SLOTLOCK_SERVICE_URL和SLOTLOCK_DASHBOARD_USERS(vars);- 两个速率限制绑定,各自的
namespace_id在你的账户中必须唯一。
- 按照上述说明创建 GitHub OAuth 应用,并将回调 URL 设置为 Worker 源站上的
<SLOTLOCK_PUBLIC_URL>/dashboard/callback。 - 在 Worker 上存储密钥:对
SLOTLOCK_GITHUB_CLIENT_ID、SLOTLOCK_GITHUB_CLIENT_SECRET和SLOTLOCK_SESSION_SECRET执行npx wrangler secret put。 - 部署:在仓库根目录运行
npm ci --omit=dev(Worker 会打包 Slotlock 源码),然后在workers/dashboard中运行npx wrangler deploy --config <your configuration>。
-
每个请求都会单独打开一个数据库客户端,最多五个连接,并在发送响应时关闭;连接池由 Hyperdrive 维护。
-
任何人都可以发起登录,每个回调都会产生一次数据库写入,并使用应用的客户端密钥向 GitHub 发出请求。一个 isolate 最多同时运行八个请求,但 Workers 会运行许多 isolate,因此两个速率限制绑定会对所有请求进行限制:
SLOTLOCK_CLIENT_RATE_LIMIT按客户端地址统计每个仪表板请求;SLOTLOCK_SIGN_IN_RATE_LIMIT将所有客户端的登录和回调合并统计。
计数按 Cloudflare 位置分别计算。缺少任一绑定时,Worker 都会返回 503。
-
只有在服务直接提供仪表板时,服务才会根据
SLOTLOCK_DASHBOARD_USERS检查仪表板租户。将仪表板部署到 Workers 后,从 Worker 的列表中移除某人即可让其退出仪表板;若要停用其密钥,请撤销密钥或删除租户。 -
缺少密钥或密钥强度不足时,Worker 会返回 503,并记录出错的变量名,但不会记录其值。
连接 MCP 客户端
将任何 Streamable HTTP 客户端指向 http://localhost:8080/mcp,并提供令牌。
Claude Code:
claude mcp add --transport http slotlock http://localhost:8080/mcp \
--header "Authorization: Bearer $SLOTLOCK_AUTH_TOKEN"
Cursor(~/.cursor/mcp.json):
{
"mcpServers": {
"slotlock": {
"url": "http://localhost:8080/mcp",
"headers": { "Authorization": "Bearer ${env:SLOTLOCK_AUTH_TOKEN}" }
}
}
}
VS Code(.vscode/mcp.json;只会要求输入一次令牌):
{
"inputs": [
{ "type": "promptString", "id": "slotlock-token", "description": "Slotlock token", "password": true }
],
"servers": {
"slotlock": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": { "Authorization": "Bearer ${input:slotlock-token}" }
}
}
}
设置 SLOTLOCK_CONFIRM_WRITES=all 后,写入操作需要客户端支持显示确认提示(MCP
2026-07-28 的表单引导);其他客户端会收到 confirmation_required。设置为 none 可允许
Agent 自行预订。
快速开始:下一个空闲时段
可用性计算无需数据库:
import { expandRules, findNextAvailable } from 'slotlock';
const searchWindow = {
start: new Date('2026-09-14T00:00:00Z'),
end: new Date('2026-09-21T00:00:00Z'),
};
const windows = expandRules(
[{ rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 8 * 60 }],
searchWindow,
'Europe/London',
);
export const slot = findNextAvailable({
windows,
busy: [{ start: new Date('2026-09-14T09:00:00Z'), end: new Date('2026-09-14T10:00:00Z') }],
durationMs: 2 * 60 * 60 * 1000,
});
// slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one
expandRules 无法评估的规则不会增加可用时段;它绝不会凭空生成时段。搜索窗口最多为 367 天(invalid_window),此处和 findNextAvailableFor 中均如此。
设置 PostgreSQL
| 角色 | 所有者 | 用途 |
|---|---|---|
| 部署 | slotlock 架构 | 每次发布运行一次 applySchema、applyTenantRls、grantApplicationRole |
| 应用程序 | 无(NOSUPERUSER NOBYPASSRLS) | 所有流量均受强制行级安全限制,仅限单个租户 |
创建一次应用程序角色(CREATE ROLE slotlock_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS;),然后以所有者身份部署:
import { createSlotlockStore } from 'slotlock';
import postgres from 'postgres';
export async function deploySlotlock(deployUrl: string, applicationRole: string): Promise<void> {
const sql = postgres(deployUrl, { max: 1, onnotice: () => {} });
try {
const store = createSlotlockStore(sql);
await store.applySchema();
await store.applyTenantRls();
await store.grantApplicationRole(applicationRole);
} finally {
await sql.end();
}
}
applySchema幂等,可安全地由多个实例同时运行。grantApplicationRole会授予对 Slotlock 表的架构使用权限和 DML 权限,并拒绝(unsafe_application_role)可能绕过行级安全的角色。每次运行applySchema后都要运行它。SECURITY.md 列出了它的检查项。- 如果
slotlock架构由其他角色控制,applySchema会拒绝执行(unsafe_slotlock_schema)。 - 已有类似
app.tenant_id的租户设置?将{ tenantContextSetting: 'app.tenant_id' }传给createSlotlockStore。
以应用程序身份预订和读取
import { type SlotlockStore, createSlotlockStore } from 'slotlock';
import postgres from 'postgres';
export function openSlotlock(applicationUrl: string) {
const sql = postgres(applicationUrl, { max: 10 });
return { sql, store: createSlotlockStore(sql) };
}
export async function bookHandover(store: SlotlockStore, tenantRef: string) {
return store.withTenant(tenantRef, async (tenant) => {
const vehicle = await tenant.createResource({
tenantRef,
externalRef: 'vehicle-42',
timezone: 'Europe/London',
});
const handover = await tenant.putCalendarEvent({
tenantRef,
externalRef: 'handover-BK-42',
idempotencyKey: 'booking-BK-42-create',
expectedRevision: 0,
resourceId: vehicle.id,
start: new Date('2027-03-29T09:00:00Z'),
end: new Date('2027-03-29T10:00:00Z'),
timezone: 'Europe/London',
summary: 'Vehicle handover',
organizer: { email: '[email protected]', name: 'Fleet Desk' },
attendees: [{ email: '[email protected]', participationStatus: 'needs_action', rsvp: true }],
reminders: [{ action: 'display', minutesBeforeStart: 30 }],
});
if (!handover.ok) throw new Error(`handover not booked: ${handover.code}`);
const freeBusy = await tenant.getFreeBusy({
tenantRef,
resourceId: vehicle.id,
window: { start: new Date('2027-03-29T00:00:00Z'), end: new Date('2027-03-30T00:00:00Z') },
});
return { vehicle, handover, freeBusy };
});
}
withTenant 会验证租户,在每次回调调用使用的同一个连接上,以事务本地方式设置上下文;它支持嵌套保存点,并会恢复之前的上下文。不要通过连接池单独执行 set_config(..., true):该事务会在下一次调用前结束。withTenant(tenantRef, callback, { isolation: 'read committed' }) 会在 READ COMMITTED 事务中运行回调,无论角色的默认隔离级别为何;若在隔离级别不同的调用方事务中,则会拒绝执行。
createResource({ ..., maxTenantResources }) 会在租户已有指定数量的资源(1-100,000)时拒绝创建新资源(resource_limit_reached);已有的 externalRef 仍会更新。为单个租户创建有上限的资源时,会获取数据库锁并在 READ COMMITTED 下计数,因此无论有多少台服务器,即使有并发请求,也会在达到上限时停止;在 REPEATABLE READ 或 SERIALIZABLE 事务中则会拒绝执行(invalid_transaction_isolation)。不设上限的创建不会加锁。
setResourceAvailability({ tenantRef, id, rules }) 为资源设置其可预订时段(null 使用默认时段,[] 表示关闭);setTenantAvailability({ tenantRef, rules }) 为租户的每项资源设置时段。createSlotlockStoreAgentBackend 会根据资源自身的时段作出响应,并且仅对没有自身时段的资源调用 availabilityRules。
写入规则:
- 使用
expectedRevision: 0创建;更新时使用上次读取的版本,并提供新的幂等键。重放相同命令和键会返回原始结果(idempotent: true);使用相同键但负载不同则返回idempotency_conflict。 - 冲突的不透明事件会返回
{ ok: false, code: 'overlap' }。透明事件永远不会阻止预订。 - 取消操作会保留墓碑,因此延迟到达的创建操作无法恢复该事件;若要创建真正的新事件,请使用新的外部引用。
listCalendarEvents和listResources最多返回 1,000 行,并使用键集游标分页。
重复事件
export async function scheduleWeeklyInspection(
store: SlotlockStore,
tenantRef: string,
resourceId: string,
) {
return store.withTenant(tenantRef, (tenant) =>
tenant.putCalendarEvent({
tenantRef,
externalRef: 'inspection-vehicle-42',
idempotencyKey: 'inspection-vehicle-42-create',
expectedRevision: 0,
resourceId,
start: new Date('2027-03-30T07:00:00Z'),
end: new Date('2027-03-30T08:00:00Z'),
timezone: 'Europe/London',
summary: 'Weekly inspection',
recurrence: { rrule: 'FREQ=WEEKLY;COUNT=8' },
materializationWindow: {
start: new Date('2027-03-30T00:00:00Z'),
end: new Date('2027-05-30T00:00:00Z'),
},
}),
);
}
事件实例最多存储 367 天和 2,000 个;维护时会向前滚动时间窗口,绝不会将窗口末尾往回移。超出窗口的时间,无论在窗口之前还是之后,空闲/忙碌报告都会标记为未经证实。
规则会在预算范围内展开:对于日历源,预算为 50,000 步和 1 秒(针对整个读取过程,从其文本开始计算);对于已存储的系列,预算适用于维护时的滚动过程;对于写入的单个事件,预算为上述的一半。日历源自身的时区(VTIMEZONE)也计入同一预算。日历源最多可包含 100 个时区,并引用 100 个未定义的时区;其包含的时区中最多可有 1,000 条观测规则和 2,000 个 RDATE 值;其事件中最多可有 20,000 个 RDATE 和 EXDATE 值。属性名称及其参数最多可占 8 KB,列表最多包含 100 项。SECONDLY 到 WEEKLY 的间隔、DURATION 或事件时长最多可为一个世纪。需要更多资源的日历(例如永不发生的规则 FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30)会被拒绝:写入时返回 invalid_event,日历源则返回 invalid_icalendar。
日历源的规则从起始时间开始展开,每个系列最多可经过 4,000 个事件实例(每日一次的规则约为 11 年),不受日历源中其他系列的影响。已存储的系列会保留上次展开停止的位置,下次从该处继续,因此每日系列只要持续运行,就会不断向前滚动:其预算针对时间窗口,而非窗口之前的年份。更改系列的写入操作会从起始时间开始搜索;只有当搜索超出预算时,才会从上次停止的位置继续。缓存位置仅适用于所保存的规则、起始时间和时区,并且必须由同一版本的 ical.js 读取;它绝不会越过仍可能进入窗口的事件实例(在事件时长加一天的范围内,或被例外移动到窗口内)。否则,系统会在同一预算范围内从系列起始时间开始搜索。维护期间,超出预算的已存储系列会计为 refused(其 ID 会列在 refusedEventIds 中),并保留原有覆盖范围。
外部日历
提供方适配器(Google、Microsoft、CalDAV)及其凭据由你的应用保管。Slotlock 为它们提供通用边界:
- Webhook 变更使用
normalizeExternalCalendarChange;使用parseICalendarChanges和emitICalendar处理有界的 iCalendar 输入和输出;使用emitITipCalendar生成邀请。 - 全天事件会转换为日历时区中的完整本地日期;没有时区的日历源将被拒绝。
- Intl 识别的 TZID 会按对应的 IANA 时区读取,无论日历源是否定义了该时区(此类名称对应的 VTIMEZONE 自身规则不会被使用);仅由日历源定义的 TZID(例如 Windows 名称)会按其 VTIMEZONE 读取。浮动时间按日历时区读取。因此,以 UTC 或其他时区书写的 UNTIL、EXDATE、RDATE 或 RECURRENCE-ID 会指向其所代表的 occurrence;浮动 UNTIL 按系列自身的时钟读取;
RANGE=THISANDFUTURE异常则会根据自身偏移移动所有后续 occurrence。 - 没有时长的事件(没有 DTEND 或 DURATION 的 DATE-TIME DTSTART)不占用时间,会被省略。PERIOD RDATE 的结束时间由其 period 决定。根元素不是 VCALENDAR 的文档,或结束时间早于开始时间的事件,将被拒绝,并返回
invalid_icalendar。 - 系列的 DTSTART 是其第一次 occurrence,无论是否带有 RRULE;每个 RRULE 都会展开;两个 RRULE 或 RRULE 与 RDATE 指向同一时刻时,只算一个 occurrence;每个 EXDATE 都会排除其所指的 occurrence(RFC 5545 §3.8.5)。定时系列的 DATE EXDATE 指向该日期在系列开始时间的 occurrence。
- occurrence 按日历显示的挂钟时间结束(时钟拨回的夜晚,00:30-02:30 会持续三小时)。如果 occurrence 在时钟跳过的时段内开始,则其持续时间仍为系列的精确时长,并从时钟跳变前的偏移开始计算(RFC 5545 §3.3.5)。
parseTrustedICalendarEvents与parseICalendarChanges一样读取时间,因此通过它存储的系列所占用的时间与日历源报告的一致。recordCalendarCoverage会标记已读取的日历源范围。将资源依赖的来源作为requiredSources传入;任何未达到complete的覆盖范围,都意味着无法证明该时段空闲。
使用 createICalendarWorkerPool 读取他人控制的日历源,该方法会在工作线程中运行相同的解析器。无论日历源的处理成本多高,为请求提供服务的线程都不会在其上运行 ical.js:超过时限(默认 3 秒)或堆内存限制(128 MB)的工作线程会被停止,日历源也会以 CalendarExpansionLimitError 拒绝。当所有工作线程都在忙且队列已满时,线程池会立即返回 ICalendarWorkerBusyError。工作线程约在 40 毫秒内启动,并会重复使用;空闲工作线程不会阻止进程退出。线程池使用构建后的软件包运行(工作线程为 ical-worker-thread.js),而不是使用 Slotlock 的 TypeScript 源码。
import {
CalendarExpansionLimitError,
ICalendarWorkerBusyError,
createICalendarWorkerPool,
} from 'slotlock';
// One pool for the process. Each feed is read on a worker thread; one that runs past its deadline
// or its heap is stopped, and the feed refused, while this thread carries on.
const feeds = createICalendarWorkerPool({ size: 2, deadlineMs: 3_000 });
export async function readFeed(text: string, window: { start: Date; end: Date }) {
try {
return {
ok: true as const,
changes: await feeds.parseICalendarChanges(text, undefined, window),
};
} catch (error) {
// Every worker is busy and the queue is full: read this feed on the next sync.
if (error instanceof ICalendarWorkerBusyError) return { ok: false as const, retry: true };
// The feed takes more than Slotlock will spend reading one: tell its owner, do not retry.
if (error instanceof CalendarExpansionLimitError) return { ok: false as const, retry: false };
throw error;
}
}
export function closeFeeds() {
return feeds.close();
}
向 Agent 提供 Slotlock
一个 HTTP 处理程序通过 MCP 和 A2A 提供工具。租户信息来自你的 authenticate,绝不会来自工具参数。
import {
type SlotlockAgentOperation,
type SlotlockAgentPrincipal,
type SlotlockStore,
createSlotlockAgentServer,
createSlotlockStoreAgentBackend,
} from 'slotlock';
import { createSlotlockNodeServer } from 'slotlock/node-server';
const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
'slotlock_list_resources',
'slotlock_get_free_busy',
'slotlock_find_next_available',
'slotlock_create_event',
'slotlock_get_event',
'slotlock_list_events',
'slotlock_update_event',
]);
export interface CalendarServerConfig {
store: SlotlockStore;
publicBaseUrl: string;
port: number;
verifyToken(token: string): Promise<SlotlockAgentPrincipal | null>;
allowInsecureLocalhost?: boolean;
confirmationSecret?: string;
}
export async function startCalendarServer(config: CalendarServerConfig) {
const server = createSlotlockAgentServer({
publicBaseUrl: config.publicBaseUrl,
allowInsecureLocalhost: config.allowInsecureLocalhost === true,
...(config.confirmationSecret
? {
confirmation: {
operations: ['slotlock_create_event', 'slotlock_update_event'] as const,
secrets: [config.confirmationSecret],
},
}
: {}),
resourceWindowDays: 90,
subscriptions: { pollIntervalMs: 5_000 },
backend: createSlotlockStoreAgentBackend(config.store, {
availabilityRules: async () => [
{ rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 480 },
],
}),
authenticate: async (request) => {
const token = /^Bearer (.+)$/.exec(request.headers.get('authorization') ?? '')?.[1];
return token ? config.verifyToken(token) : null;
},
authorize: async ({ operation }) => ALLOWED_OPERATIONS.has(operation),
health: async () => ({ ready: true, checks: ['database'] }),
});
const listener = createSlotlockNodeServer(server, {
requestOrigin: new URL(config.publicBaseUrl).origin,
});
await listener.listen({ host: '127.0.0.1', port: config.port });
return listener;
}
- 每次调用都会运行
authorize,并传入当前工具名称,即使客户端使用的是旧别名也一样。建议使用允许列表,这样后续版本新增的工具在你允许之前都不会开放。 - 对于没有自定义规则的资源,
availabilityRules会返回默认可预订时段。没有规则就表示永不可预订。 confirmation会让列出的写入操作等待人工确认(写入前确认)。publicBaseUrl必须使用 HTTPS(allowInsecureLocalhost允许在开发期间使用http://localhost)。请将 Node 监听器置于 TLS 代理后,或在任意 Fetch 运行时中挂载server.fetch。- 服务器不会发送 CORS 标头:请从后端调用它,而不是从网页调用。
端点
| 方法和路径 | 说明 |
|---|---|
POST {base}/mcp | 基于 Streamable HTTP 的 MCP,无状态 |
POST {base}/a2a | A2A 1.0 JSON-RPC |
GET {base}/.well-known/agent-card.json | A2A Agent 卡片,也可从源站根路径访问 |
GET {base}/manifest.json | 协议版本、端点以及每个工具的 JSON Schema |
GET {base}/healthz | 根据你的 health 回调返回就绪状态 |
GET /.well-known/oauth-protected-resource{/path} | 设置 oauth 时提供 OAuth 元数据 |
{base}/v1/…, GET {base}/openapi.json | REST API 及其 OpenAPI 3.1 文档,启用 rest 时提供 |
工具
| 工具 | 功能 | 提示 |
|---|---|---|
slotlock_list_resources | 列出租户可见的资源 | 只读 |
slotlock_get_free_busy | 查询繁忙时段及其覆盖范围的确定性 | 只读 |
slotlock_find_next_available | 查找所有资源中最早的确定可用时段 | 只读 |
slotlock_list_events | 列出调用者在指定时间范围内创建的事件 | 只读 |
slotlock_get_event | 获取调用者创建的一个事件 | 只读 |
slotlock_create_event | 创建最长 3,660 天的事件(幂等) | 写入 |
slotlock_update_event | 按预期修订版本更新事件 | 写入 |
slotlock_delete_event | 按预期修订版本将事件标记为已删除 | 写入,有破坏性 |
查询时间范围最长为 367 天。每位调用者只能看到自己创建的事件,但所有人的事件都会计入繁忙时间。旧名称 calendar.<verb> 和 calendar_<verb> 仍可使用,但不再对外公布。
错误
拒绝请求会以模型可采取行动的结果形式返回:
- MCP:
isError: true,文本为{"error":{"code":"…"}}。 - A2A:回复的数据部分为
{"error":{"code":"…"}}。
| 代码 | 含义 |
|---|---|
invalid_arguments | 输入未通过 Schema 验证,或时间范围颠倒或超过 367 天 |
forbidden | authorize 拒绝了调用 |
rate_limited | consumeRateLimit 拒绝了调用 |
not_found, resource_not_found, event_not_found | 此调用者不可见,或不存在此 ID:资源 ID 是 slotlock_list_resources 返回的 id,而非其 external_ref |
overlap | 该时段已被占用 |
revision_conflict, idempotency_conflict, event_cancelled | 重新读取,然后使用当前修订版本或新密钥重试 |
owner_event_quota_exceeded, owner_command_quota_exceeded | 已达到单个调用者的配额(参见运维) |
confirmation_required | 此写入需要由人员确认,而此客户端无法请求确认 |
confirmation_declined, confirmation_cancelled | 人员拒绝或关闭了确认请求 |
invalid_cursor, invalid_event, invalid_window, … | 存储端拒绝了受限输入 |
request_aborted, store_inconsistent | 临时错误;请重试 |
来自你自己的后端的错误
抛出 new SlotlockAgentOperationError('snake_case_code', status),Agent 就会收到该代码。
其他任何异常都属于服务器故障,其消息绝不会离开服务器:Agent 会收到
internal_error,而 onError(error, { operation }) 会收到该错误;操作输出 Schema 之外的结果也会如此,因此你可以记录错误。slotlock serve 会在 stderr 上以 agent_error 行记录该错误。
协议错误
| 位置 | 错误 |
|---|---|
| HTTP | 401 并附带 WWW-Authenticate,403 表示来源不允许,413/415 表示请求体错误,429 表示速率限制(MCP、REST) |
| MCP | -32602 表示未知工具、非对象类型的 arguments 或被篡改的确认;-32603 表示服务器故障;-32601 表示未知方法 |
| MCP 2026-07-28 | -32020 表示标头与请求体不一致,-32021 表示未声明表单引导,-32022 表示不支持的修订版本,-31029 表示速率限制 |
| A2A | -32602 表示消息或参数格式错误,-32029 表示速率限制(RATE_LIMITED,与所有 A2A 错误一样,位于 200 响应封套中),-32009 表示不支持的 A2A-Version,-32003 表示推送通知,-32004 表示流式传输,-32001 表示未找到任务(服务器不保留任务) |
身份验证和 OAuth 发现
authenticate 会检查你签发的所有 bearer token。要让支持 OAuth 的 MCP 主机找到你的授权服务器,请发布 RFC 9728 元数据:
import type { SlotlockAgentServerOAuthOptions } from 'slotlock';
export const oauth: SlotlockAgentServerOAuthOptions = {
authorizationServers: ['https://auth.example.com'],
scopesSupported: ['calendar:read', 'calendar:write'],
requiredScopes: ['calendar:read'],
};
随后,来自 /mcp 的 401 会将客户端指向该元数据,并要求提供 requiredScopes。Slotlock 只负责发布元数据:authenticate 仍必须验证每个 token,包括其受众。
写入前确认
启用 confirmation 后,列出的写入操作会先向用户询问是或否,并准确说明将发生什么变化,例如 Book vehicle-42 for 2027-03-29 10:00–11:00 (Europe/London): "Vehicle handover". 只有用户明确回答是,才会执行写入。
- 如果预订不会占用该时段(
transparency: 'transparent'),确认内容会说明这一点;写入还会列出所设置的其他字段(also description, attendees),但不会引用这些字段的值。 - 先说明事实:使用资源自身的引用标识、其所在时区的时间,以及通过你的后端
describeResource和getEvent读取的事件当前状态(当调用方有权读取且两次读取总耗时不超过 2 秒时)。否则使用 ID 指明对象,并为每个时间标注 UTC 偏移量。最后才引用 Agent 自己提供的文本。 - 重复预订会说明预订次数、首次和末次时间,以及移动的每次发生时间;这些信息来自存储端实际预订所使用的同一次展开结果。修改单次预订时,会说明修改涵盖所有重复项。Agent 可以发送按每日或更低频率重复、且能够发生的 RFC 5545 规则。
- 待确认问题会通过 HMAC-SHA256 绑定到调用方、工具和精确参数,并在
ttlSeconds(600)后过期。 - 无法提问的客户端(MCP 2025 修订版、A2A)会收到
confirmation_required;不会写入任何内容。 secrets保存 1 到 8 个长度至少为 32 字节的密钥:轮换时,将新密钥列在最前面。onEvent会报告每项结果以供审计。
日历资源和实时更新
在 MCP 2026-07-28 中,每个资源也是一个 MCP 资源 slotlock://resources/{id},读取时会返回接下来 resourceWindowDays(30)天的忙碌时间和覆盖范围。subscriptions/listen 会在资源的空闲/忙碌状态发生变化时,推送 notifications/resources/updated。
- 每隔
subscriptions.pollIntervalMs(10 s),通过你的后端重新读取并检查变化;每次都重新检查调用方。 - 订阅会在达到
maxDurationMs(15 minutes)、调用shutdown()或访问权限丢失时结束。 - 每位调用方最多有 4 个打开的订阅,每台服务最多有 256 个(
maxPerPrincipal、maxTotal)。subscriptions: false会关闭订阅。
缓存与追踪
2026-07-28 的结果会携带缓存提示:发现结果和工具列表公开缓存一小时,空闲/忙碌信息永不缓存。W3C traceparent、tracestate 和 baggage 会作为 context.trace 传递到你的后端。
onEvent 会接收每个指定了操作的调用通知,因此你可以对其计数、计时并进行关联:
- 对于 MCP
tools/call或 A2ASendMessage,事件为{ type: 'call', protocol, operation, outcome, durationMs, traceId? };outcome为ok、拒绝所用的代码(overlap、rate_limited、……),或运行前拒绝的原因:invalid_arguments、missing_capability(客户端无法确认的受保护写入)、invalid_message或invalid_message_parameters(A2A)。 - 对于 REST 调用,或运行前拒绝的调用,事件为
{ type: 'rest', operation, status, durationMs, traceId? }:请求体不是 JSON(415、400)、体积过大(413)或不是对象(400)。 - 每次确认都会在其决定的调用之前触发事件
{ type: 'confirmation', operation, outcome }。 - 如果请求在读取操作前就被拒绝,则不会上报:凭证缺失、触发速率限制、
A2A-Version不是 1.0、无法解析的 JSON-RPC 信封,或没有路由匹配的路径。 traceId是_meta或请求头中有效traceparent的追踪 ID。不会上报参数、调用方、父级 span、tracestate和baggage;如果onEvent或onError抛出异常,或返回被拒绝的 promise,不会改变客户端看到的任何内容。不会上报资源读取和订阅轮询。
slotlock serve 会将每个事件写为一行 JSON("event":"call"、duration_ms、trace_id)。
REST 和 OpenAPI
rest: true 还会将每个工具作为普通 HTTP 服务提供,并在 /openapi.json 提供 OpenAPI 3.1 文档。slotlock serve 默认启用 REST;设置 SLOTLOCK_REST_API=off 可将其关闭。
| 请求 | 工具 |
|---|---|
GET /v1/resources | slotlock_list_resources |
GET /v1/free-busy | slotlock_get_free_busy |
GET /v1/next-available | slotlock_find_next_available |
GET /v1/events | slotlock_list_events |
POST /v1/events | slotlock_create_event |
GET /v1/events/{event_id} | slotlock_get_event |
PATCH /v1/events/{event_id} | slotlock_update_event |
DELETE /v1/events/{event_id} | slotlock_delete_event |
curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_API_KEY"
GET和DELETE将工具参数作为查询参数(列表参数会重复出现:resource_ids=a&resource_ids=b);POST、PUT和PATCH使用 JSON 请求体。ID 位于路径中;如果 ID 包含/,请对其进行百分号编码。- 密钥、作用域、
authorize和速率限制的适用方式与 MCP 相同。请求体为工具的输出,或带有匹配 HTTP 状态码的{"error":{"code":…}}。 - REST 无法请求人员确认,因此
confirmation中列出的写入操作会返回428 confirmation_required。 - 不提供 CORS 响应头:请从服务器调用,不要从浏览器调用。
- 在 Python、Go、Rust、Java 或 .NET 中,可使用客户端:每个操作都有一个方法,其中也包含应用程序 API,并且都在 CI 中针对服务器进行测试。若使用其他语言,可通过任意 OpenAPI 3.1 生成器,从
openapi.json(或服务器的/openapi.json)生成客户端。
应用 API
无论使用哪种语言,Agent 周边的应用都负责处理哪些事项:商店的资源、营业时间、暂存、预订、覆盖范围和维护调用;这些功能以 REST API 的形式与工具并列。它需要一个具有 admin 作用域的密钥(在控制面板中显示为“Full”);服务器自身的 SLOTLOCK_AUTH_TOKEN,也就是发给 Agent 的令牌,可以读写,但永远不是管理员。它绝不会是 MCP 工具或 A2A 技能,也永远不会等待确认。
| 请求 | 功能 |
|---|---|
POST /v1/resources | 使用此 external_ref 创建资源,或更新其 timezone |
GET /v1/resources/{resource_id} | 获取单个资源及其每周营业时间 |
PUT /v1/resources/{resource_id}/availability | 设置该资源每周营业时间;[] 表示永不可预订,null 表示使用服务器默认设置 |
PUT /v1/availability | 一次设置所有资源的每周营业时间 |
POST /v1/holds | 暂存一个时间段,持续 ttl_seconds(最长 7 天) |
POST /v1/holds/{hold_id}/confirm | 将暂存转为预订 |
DELETE /v1/holds/{hold_id} | 释放暂存 |
PUT /v1/reservations/{reservation_ref} | 创建预订(revision: 1)或改期(当前修订版本加 1) |
DELETE /v1/reservations/{reservation_ref}?revision=… | 取消,修订版本为当前修订版本加 1 |
PUT /v1/resources/{resource_id}/coverage/{source} | 记录 source 的事件已完整覆盖某个时间段 |
POST /v1/maintenance | 为密钥所属的租户运行维护 |
curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
-H "Authorization: Bearer $SLOTLOCK_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"resource_id":"<id>","start":"2027-03-29T09:00:00Z","end":"2027-03-31T09:00:00Z",
"buffer_after_minutes":60,"revision":1}'
- 已被占用的时间段会返回
409 overlap。buffer_after_minutes会让预订结束后的时间保持占用状态,对暂存和其他预订一视同仁。 - 所有写入操作都可以安全重试:当前修订版本对应的相同预订会返回
replayed: true;已确认的暂存可以再次确认;已取消的引用会一直保持取消状态(迟到的预订会返回409)。迟到的预订副本,或已被后续操作取代的改期,会返回409 stale_revision。 - 日历事件占用的时间由事件本身决定:预订调用不会查找、改期或取消该事件,无论指定什么引用。请直接修改事件。
- 租户由密钥决定。其他租户的资源或暂存都会返回
404,无论数据库角色是什么。 - 使用该库时,将
application与rest: true一并传入:{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }。 如果主体具有scopes,则需要admin;之后由authorize决定。
连接 Agent
MCP 和 A2A 是线协议,因此任何语言的 Agent 都可以使用自己的 MCP 或 A2A 客户端,以 API 密钥作为 bearer token 连接到 /mcp 或 /a2a。以下示例使用 TypeScript。无需 Agent、直接调用 Slotlock 的代码,可以使用适用于 Python、Go、Rust、Java 和 .NET 的 REST 客户端。
MCP(任何客户端)
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
export async function findSlotOverMcp(mcpUrl: string, token: string, resourceId: string) {
const client = new Client({ name: 'fleet-assistant', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
requestInit: { headers: { Authorization: `Bearer ${token}` } },
});
await client.connect(transport);
try {
const result = await client.callTool({
name: 'slotlock_find_next_available',
arguments: {
resource_ids: [resourceId],
start: '2027-03-29T00:00:00Z',
end: '2027-04-05T00:00:00Z',
duration_minutes: 120,
},
});
if (result.isError) {
const [text] = result.content as Array<{ type: 'text'; text: string }>;
return { error: JSON.parse(text?.text ?? '{}').error as { code: string } };
}
return { slot: result.structuredContent };
} finally {
await client.close();
}
}
拒绝或冲突会作为结果(isError)返回,而不是抛出异常。对于请求使用 MCP 2026-07-28 的客户端,Slotlock 会使用该版本进行通信;对于其他客户端,则使用 2025-11-25 或 2025-06-18。
MCP 2026-07-28:确认和实时更新
使用 TypeScript SDK v2(@modelcontextprotocol/client)时,客户端会向用户展示 Slotlock 的确认请求,并接收变更通知:
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { slotlockCalendarResourceUri } from 'slotlock';
export interface LiveCalendarOptions {
mcpUrl: string;
token: string;
resourceId: string;
confirm(message: string): Promise<boolean>;
onChange(uri: string): void;
}
export async function openLiveCalendar(options: LiveCalendarOptions) {
const client = new Client(
{ name: 'fleet-assistant', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } },
);
client.setRequestHandler('elicitation/create', async (request) =>
(await options.confirm(request.params.message))
? { action: 'accept' as const, content: { confirm: true } }
: { action: 'decline' as const },
);
client.setNotificationHandler('notifications/resources/updated', (notification) =>
options.onChange(notification.params.uri),
);
await client.connect(
new StreamableHTTPClientTransport(new URL(options.mcpUrl), {
requestInit: { headers: { Authorization: `Bearer ${options.token}` } },
}),
);
const uri = slotlockCalendarResourceUri(options.resourceId);
const subscription = await client.listen({ resourceSubscriptions: [uri] });
return {
watching: subscription.honoredFilter.resourceSubscriptions ?? [],
book: (booking: Record<string, unknown>) =>
client.callTool({
name: 'slotlock_create_event',
arguments: { resource_id: options.resourceId, ...booking },
}),
read: async () => {
const [content] = (await client.readResource({ uri })).contents;
return content && 'text' in content ? JSON.parse(content.text) : null;
},
close: async () => {
await subscription.close();
await client.close();
},
};
}
Claude Code 和 Claude API
Claude Code:
claude mcp add --transport http slotlock https://calendar.example.com/slotlock/mcp \
--header "Authorization: Bearer $SLOTLOCK_TOKEN"
Claude API(MCP 连接器,anthropic-beta: mcp-client-2025-11-20;服务器必须通过 HTTPS 公开访问):
{
"model": "<model id>",
"max_tokens": 1024,
"mcp_servers": [
{
"type": "url",
"url": "https://calendar.example.com/slotlock/mcp",
"name": "slotlock",
"authorization_token": "<token your authenticate accepts>"
}
],
"tools": [{ "type": "mcp_toolset", "mcp_server_name": "slotlock" }],
"messages": [{ "role": "user", "content": "When is vehicle 42 free for two hours next week?" }]
}
A2A 1.0
import { SendMessageRequest } from '@a2a-js/sdk';
import { ClientFactory } from '@a2a-js/sdk/client';
export async function listResourcesOverA2a(baseUrl: string, token: string) {
const client = await new ClientFactory().createFromUrl(baseUrl);
const reply = await client.sendMessage(
SendMessageRequest.fromJSON({
message: {
messageId: crypto.randomUUID(),
role: 'ROLE_USER',
parts: [
{
data: { skill: 'slotlock_list_resources', arguments: { limit: 10 } },
mediaType: 'application/json',
},
],
},
}),
{ serviceParameters: { Authorization: `Bearer ${token}` } },
);
const part = 'parts' in reply ? reply.parts[0] : undefined;
return part?.content?.$case === 'data' ? part.content.value : undefined;
}
发送一个 application/json 数据部分,内容为 {"skill": "<id>", "arguments": {…}},并设置请求头 A2A-Version: 1.0。Agent 卡片中的每项技能都附有示例;回复均为同步返回。
MCP Apps
只读工具会链接到 ui://slotlock/calendar,这是一个独立的日历视图,用于在沙箱中托管支持的 MCP Apps(io.modelcontextprotocol/ui)。它不会发起网络请求,也不会写入数据。
运维
每个租户至少需要每天维护一次:将周期性事件延长至滚动期限,并清理保留窗口不再需要的内容,从而返还 Agent 配额。
slotlock serve在启动时维护SLOTLOCK_TENANT,并维护它所服务的每个租户;之后每小时逐个维护一个租户。它会记录一条带有计数的maintenance日志。slotlock maintain立即运行单个租户的维护,适用于 cron 或批量导入之后。管理员密钥可以调用POST /v1/maintenance。- 每次维护都会持有按租户隔离的 PostgreSQL advisory 锁,因此服务和 cron 任务不会重复彼此的工作;如果发现锁已被占用,本次维护便不会执行任何操作。
在你自己的进程中:
import { type SlotlockStore, maintainSlotlockTenant } from 'slotlock';
export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
// Extends recurring events to the rolling horizon, then prunes past the retention window, each in
// at most ten batches. `capped: true` means work was left: run another pass.
return maintainSlotlockTenant(store, tenantRef, { batches: 10 });
}
- 每个租户。 强制 RLS 会阻止应用角色查看其他租户的数据,因此请遍历你自己的租户列表。
startSlotlockMaintenance({ store, sql, tenants, intervalMs })是serve使用的调度器,其中包含锁;withSlotlockMaintenanceLock则只负责加锁。 - 有上限的维护轮次。 如果某次有上限的维护轮次没有延长或清理任何内容,说明它只遇到了无法延长的系列(计入
conflicts和refused):再次运行也会如此,因此请等到下一个间隔。 - 自行分页滚动期限。
rollCalendarEventHorizon会返回next;将其作为after传入同一轮维护的下一批次。处于冲突或拒绝状态的系列会保留在排序列表的前面,因此如果不传入after,每个后续批次都会再次检查它。 - 配额。 每个 Agent 主体最多可保留 1,000 个事件标识(活动和已取消的事件合计)以及 10,000 条幂等命令,其中最多 9,000 条可用于创建或更新,因此取消操作始终有足够空间。使用
createSlotlockStore上的agentOwnerEventQuota和agentOwnerCommandQuota进行调整。pruneCalendarEventRetention用于返还配额。 - 保留期限。 命令是重放窗口内精确重放的依据,默认窗口为
SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS(30)天:清理时会删除所有者的较旧命令,然后删除窗口之前且不再关联任何命令的 Agent 所有墓碑。internal墓碑(提供方和同步权限)会一直保留,直到你删除租户。超过窗口后,重试的命令会根据其预期版本重新评估,已清理的 Agent 标识可以再次创建。retentionDays(1-3650)可用于更改窗口;请将其设置得长于客户端的所有重试期限。 - 擦除。 将 Slotlock 资源、预留、墓碑、归档、事件内容、发生记录、命令和覆盖范围记录纳入租户擦除工作流及删除顺序,并使用
createSlotlockApiKeyStore(sql).erase({ tenantRef })删除该租户的 API 密钥。只会保留其 SHA-256 摘要,不包含任何租户信息,因此已擦除的密钥永远不会再被接受。仪表板记录不包含租户信息,并会在 12 小时内过期;表单记录会包含提交该记录的 GitHub 用户 ID 的 SHA-256 摘要。
生产环境检查清单
- 使用支持 TLS、备份和时间点恢复的 PostgreSQL。
- 以所有者身份部署;使用由
grantApplicationRole授予的NOBYPASSRLS角色提供服务 (slotlock serve会拒绝超级用户或BYPASSRLS角色)。 - 在
withTenant中运行每次调用;使用NOBYPASSRLS角色测试隔离。 - 先验证提供商 Webhook,再将其规范化;不要将提供商凭据存放在 Slotlock 中。
- 对序列化失败设置重试上限;对结果未知的写入,切勿盲目重试。
- 使用持久化游标和覆盖时间窗口协调外部日历。
- 监控冲突、被拒绝的规则、同步延迟、有上限的维护任务运行、配额错误和确认结果。
- 在策略要求人工介入的场景中,确保写入操作必须经过确认。
- 设定实时更新的规模:每个
pollIntervalMs最多执行maxTotal次后端读取。 - 为每个 Agent 配置单独的 API 密钥,使用最小权限范围并设置过期时间。
- 每次部署后,使用短期有效的管理员密钥进行端到端检查,然后撤销该密钥:
SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs会检查健康状态、 发现、MCP、A2A、保留时段和预订(在名为slotlock-smoke的资源中,设定在 400 天后, 随后取消),以及 401、409、413 和 415 拒绝响应。
支持的版本
| 版本 | |
|---|---|
| MCP | 2026-07-28(无状态)、2025-11-25、2025-06-18 |
| A2A | 1.0,JSON-RPC 绑定 |
| MCP Apps | io.modelcontextprotocol/ui 2026-01-26 |
| Node.js | ≥ 22.12(CI:22 和 24) |
| PostgreSQL | 16 |
| postgres(peer) | ^3.4.5 |
项目状态与边界
0.1 是核心:可用性计算、保留时段和预订、带重复规则的事件、考虑覆盖范围的空闲/忙碌状态、 租户隔离、iCalendar 互操作、支持人工确认和实时更新的 MCP 与 A2A,以及 MCP Apps 视图。提供商同步(Google、Microsoft、CalDAV)、向人员发送通知以及人工日历应用,都由你自行构建。
事件必须有确定的时长(全天事件以当地完整日为单位;单个事件最长可持续 3,660 天)。 系统会拒绝开放式条目,而不会自行猜测。
规范规则见 SPEC.md;工具无需运行程序包代码即可读取
slotlock.manifest.json。请按 SECURITY.md 中的说明报告
漏洞。项目运行方式见 CONTRIBUTING.md
和 GOVERNANCE.md。
许可证
Apache-2.0。版权所有 2026 TREFT LTD。依赖项声明见 NOTICE。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。