mcpField
S
Slotlock

Slotlock

本地服务
slotlock.pylota.io生产力0今天更新

关于 Slotlock

Slotlock 是供 AI Agent 和开发者使用的日历服务,可为房间、车辆或人员等资源安排预订。PostgreSQL 负责阻止冲突预订;查询可区分已确认的空闲时段与尚未同步、无法确认的时段。

主要功能

  1. 01按每周规则设置可用时间
  2. 02查询忙闲和下一个空档
  3. 03创建和更新资源预订
  4. 04通过 PostgreSQL 防止时间冲突
  5. 05支持幂等写入和版本校验
  6. 06按权限范围管理 API 密钥

适用场景

  1. 01查找可用车辆或房间
  2. 02避免资源预订时间重叠
  3. 03确认未同步日历时段是否确实空闲

MCP 相关

通过 `/mcp` 提供基于 Streamable HTTP 的 MCP 工具,并使用 Bearer token 认证。Agent 可按密钥权限查询可用时间,并创建、更新或删除预订。

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

mcp.json · 通用配置
{
"mcpServers": {
"pilotaai-slotlock": {
"command": "docker",
"args": [
"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.0"
],
"env": {
"DATABASE_URL": "<DATABASE_URL>",
"SLOTLOCK_AUTH_TOKEN": "<SLOTLOCK_AUTH_TOKEN>",
"SLOTLOCK_CONFIRMATION_SECRET": "<SLOTLOCK_CONFIRMATION_SECRET>",
"SLOTLOCK_METRICS_TOKEN": "<SLOTLOCK_METRICS_TOKEN>",
"PORT": "<PORT>",
"SLOTLOCK_MIGRATE_DATABASE_URL": "<SLOTLOCK_MIGRATE_DATABASE_URL>",
"SLOTLOCK_CONFIRM_WRITES": "<SLOTLOCK_CONFIRM_WRITES>",
"SLOTLOCK_AVAILABILITY": "<SLOTLOCK_AVAILABILITY>",
"SLOTLOCK_REST_API": "<SLOTLOCK_REST_API>",
"SLOTLOCK_RETENTION_DAYS": "<SLOTLOCK_RETENTION_DAYS>",
"SLOTLOCK_SHARED_LIMITS": "<SLOTLOCK_SHARED_LIMITS>",
"SLOTLOCK_WEBHOOKS": "<SLOTLOCK_WEBHOOKS>",
"SLOTLOCK_TENANT": "<SLOTLOCK_TENANT>"
}
}
}
}
启动命令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.0
需要配置的环境变量
DATABASE_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+)。各客户端的配置文件位置不同,请参考客户端文档。

安装包
ocighcr.io/pilotaai/slotlock:0.6.0

工具

还没有获取到工具列表

本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。

详细介绍

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/mcpMCP,使用 Authorization: Bearer <SLOTLOCK_AUTH_TOKEN>
http://localhost:8080/a2aA2A 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:

  1. 在 GitHub 上,打开 Settings、Developer settings、OAuth apps、New OAuth App,并将 “Authorization callback URL”设为 <SLOTLOCK_PUBLIC_URL>/dashboard/callback。然后在应用页面上生成 客户端密钥。
  2. 根据应用设置 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 账户登录。
  3. 运行 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 的令牌可以替换此令牌并读取其会话密钥,从而以任意租户的身份登录。

  1. 为该服务角色创建一个 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
    
  2. 在配置副本中设置:
    • 配置的 id;
    • 你的主机名(routes);
    • SLOTLOCK_PUBLIC_URL、SLOTLOCK_SERVICE_URL 和 SLOTLOCK_DASHBOARD_USERS(vars);
    • 两个速率限制绑定,各自的 namespace_id 在你的账户中必须唯一。
  3. 按照上述说明创建 GitHub OAuth 应用,并将回调 URL 设置为 Worker 源站上的 <SLOTLOCK_PUBLIC_URL>/dashboard/callback。
  4. 在 Worker 上存储密钥:对 SLOTLOCK_GITHUB_CLIENT_ID、SLOTLOCK_GITHUB_CLIENT_SECRET 和 SLOTLOCK_SESSION_SECRET 执行 npx wrangler secret put。
  5. 部署:在仓库根目录运行 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}/a2aA2A 1.0 JSON-RPC
GET {base}/.well-known/agent-card.jsonA2A 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.jsonREST 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 天
forbiddenauthorize 拒绝了调用
rate_limitedconsumeRateLimit 拒绝了调用
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 行记录该错误。

协议错误
位置错误
HTTP401 并附带 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 或 A2A SendMessage,事件为 { 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/resourcesslotlock_list_resources
GET /v1/free-busyslotlock_get_free_busy
GET /v1/next-availableslotlock_find_next_available
GET /v1/eventsslotlock_list_events
POST /v1/eventsslotlock_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 摘要。
生产环境检查清单
  1. 使用支持 TLS、备份和时间点恢复的 PostgreSQL。
  2. 以所有者身份部署;使用由 grantApplicationRole 授予的 NOBYPASSRLS 角色提供服务 (slotlock serve 会拒绝超级用户或 BYPASSRLS 角色)。
  3. 在 withTenant 中运行每次调用;使用 NOBYPASSRLS 角色测试隔离。
  4. 先验证提供商 Webhook,再将其规范化;不要将提供商凭据存放在 Slotlock 中。
  5. 对序列化失败设置重试上限;对结果未知的写入,切勿盲目重试。
  6. 使用持久化游标和覆盖时间窗口协调外部日历。
  7. 监控冲突、被拒绝的规则、同步延迟、有上限的维护任务运行、配额错误和确认结果。
  8. 在策略要求人工介入的场景中,确保写入操作必须经过确认。
  9. 设定实时更新的规模:每个 pollIntervalMs 最多执行 maxTotal 次后端读取。
  10. 为每个 Agent 配置单独的 API 密钥,使用最小权限范围并设置过期时间。
  11. 每次部署后,使用短期有效的管理员密钥进行端到端检查,然后撤销该密钥: SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs 会检查健康状态、 发现、MCP、A2A、保留时段和预订(在名为 slotlock-smoke 的资源中,设定在 400 天后, 随后取消),以及 401、409、413 和 415 拒绝响应。

支持的版本

版本
MCP2026-07-28(无状态)、2025-11-25、2025-06-18
A2A1.0,JSON-RPC 绑定
MCP Appsio.modelcontextprotocol/ui 2026-01-26
Node.js≥ 22.12(CI:22 和 24)
PostgreSQL16
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。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。