Slotlock について
Slotlockは、部屋、車両、人などの共有リソースを予約するAIエージェントや開発者向けのカレンダーサービスです。PostgreSQLで予約の競合を防ぎ、空き状況の照会では、確認済みの空き時間と未同期で未確定の時間を区別できます。
主な機能
- 01週間ルールによる予約可能時間の設定
- 02空き状況と次の空き枠の照会
- 03リソース予約の作成と更新
- 04PostgreSQLによる予約競合の防止
- 05リビジョン指定の冪等な書き込み
- 06スコープ付きAPIキー
活用シーン
- 01空いている車両や部屋の検索
- 02リソース予約の時間重複の防止
- 03未同期の時間が本当に空いているか確認
MCP 対応
`/mcp`でBearerトークン認証のStreamable HTTP対応MCPツールを提供します。エージェントはキーのスコープに応じて空き状況を確認し、予約の作成、更新、削除を行えます。
設定
ローカルサーバーの設定はどのクライアントでも共通です。コピーしてクライアントの 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エージェント向けのカレンダーです。車両、部屋、機械、人、その他予約可能なものを予約し、週ごとの空き時間ルールに基づいて「いつ空いていますか?」に回答します。また、同時に何人の書き込みが競合しても、PostgreSQLが二重予約を拒否します。エージェントはMCPまたはA2A経由で呼び出し、コードからはTypeScriptライブラリとして利用できます。
エージェント向けに設計されている理由
- カレンダーを所有するのはユーザーではなくリソースで、テナントは認証済みの呼び出し元からのみ取得されます。
- 期間は半開区間
[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と同様に、2つのロールを使用します。
サーバーは何も所有せず、行レベルセキュリティを回避できないロールで接続し、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キー
サーバートークンを共有するのではなく、エージェントごとに専用のキーを発行します。
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)で動作するため、1つのサーバーで複数のテナントにサービスを提供できます。 readは参照のみを行う5つのツールに適用され、writeは作成、更新、削除に適用されます。adminはアプリケーションAPIに適用されますが、エージェントが使用する必要はありません。デフォルトはreadとwriteです。rotateはキーのID、スコープ、予約を維持します。revokeは次のリクエストからキーを無効にします。- 1つのテナントが保持できる有効なキーは100個です。末尾6文字は、シークレットスキャナーがオフラインで検証できるチェックサムです。
ダッシュボード
サーバーでは、ユーザーが GitHub でサインインし、自分のキーやリソースを管理できるダッシュボードも提供できます。そのため、ユーザーごとに slotlock key create を実行しなくても、それぞれに API キーを渡せます。
- 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)を設定します。また、サインインを許可する GitHub ユーザー ID をSLOTLOCK_DASHBOARD_USERSに設定するか(gh api users/<login> --jq .id)、すべての GitHub アカウントを許可する場合は*を設定します。 slotlock serveを実行し、<SLOTLOCK_PUBLIC_URL>/dashboardを開きます。Compose を使う場合は、4つの値を.envに設定して(空欄のままにすると、既定でダッシュボードは無効になります)、docker compose up --detachを実行します。
- ユーザーごとに専用のテナント
github:<user id>が割り当てられ、そのテナント内のキー、リソース、予約はそのユーザーだけが使用できます。slotlock key createで作成したキーは、代わりにSLOTLOCK_TENANTで動作します。 - サインインでは GitHub にスコープを要求せず、
stateと PKCE を使用します。GitHub からはユーザー ID とログイン名以外の情報を保持しません。GitHub のトークンは一度だけ使用され、その後破棄されます。 - セッションは署名付きの HttpOnly Cookie で、有効期間は12時間です。許可リストはダッシュボードと API の両方を含むすべてのリクエストで確認されます。ID を削除してサーバーを再起動すると、そのユーザーはサインアウトされ、テナント内のすべてのキーがただちに無効になります。
- すべてのフォームにはセッションに紐付いたトークンが含まれ、サーバー自身のオリジンから送信される必要があります。ページではインラインスクリプトを実行せず、自身のファイルのみを許可する Content-Security-Policy を適用します。
- ダッシュボードには専用のオリジン(他のアプリと同じパスではなく、
https://slotlock.example.comなど)を割り当ててください。Cookie はホスト全体で有効なため、同じオリジン上の他のアプリから読み取られる可能性があります。 - 完了したサインイン(コールバックは一度だけ機能します)、サインアウト済みのセッション(コピーした Cookie でもアクセスできません)、送信済みのフォーム(再読み込みしてもキーの作成やローテーションは一度しか行われません)は Postgres に記録されるため、ロードバランサーの背後でデータベースを共有しているすべてのサーバーで有効です。記録されるのはそれぞれ SHA-256 ダイジェストで、Cookie またはフォームの有効期限が切れるまで保持されます(最長12時間)。フォームの記録には、送信した GitHub ユーザー ID のダイジェストも含まれます。サーバーロールは、2つの SECURITY DEFINER 関数を通じてのみこれらの記録にアクセスできます。
serveはmigrateによって権限が付与されるまで、ダッシュボードを有効にして起動することを拒否します。 - サインインまたはセッションの有効期限はデータベースの時計で判定されるため、データベースや他のサーバーと時計がずれているサーバーでも、リプレイやサインアウト済みのセッションを通過させることはありません。それでも、時計は同期させてください。サーバーの時計がデータベースより12時間以上進んでいると、誰もサインインできません。
- 各フォームにはセッションに紐付いた使い捨ての値が含まれます。ユーザーが同時に記録できるフォームは1,000件(12時間分)までです。上限を超えると、最も古い記録が期限切れになるまでダッシュボードは 429 を返します。サインアウトは常に機能します。
- ユーザーが追加できるリソースは100件です。上限はデータベースで管理されるため、何台のサーバーで追加が競合しても100件で止まります。すでに所有しているリソースのタイムゾーンは引き続き変更できます。
- 各リソースは、所有者が営業時間ページで個別に設定するまで、サーバーの予約可能時間(
SLOTLOCK_AVAILABILITY)を使用します。時間は1日2つの時間帯、日をまたぐ時間帯、終日、または休業に設定できます。1つのフォームで、すべてのリソースの時間を一括設定できます。
Cloudflare Workers 上のダッシュボード
同じダッシュボードを独自のオリジン(workers/dashboard)で Cloudflare Worker として実行でき、エージェントは引き続きサーバーを使用します。サーバーの MCP および A2A の URL(SLOTLOCK_SERVICE_URL)を表示し、サーバーと同じ役割で Hyperdrive 経由でサーバーのデータベースに接続します。
workers/dashboard/wrangler.jsonc は設定例です。これを独自のデプロイ用にコピーし、自分の設定として示されている箇所を置き換えてください。このリポジトリの Dashboard ワークフローで確認するのは、Worker がバンドルされることだけです。独自の Cloudflare トークンを使い、独自のパイプラインからデプロイしてください。Workers をデプロイできるトークンはこのトークンの代わりに使用でき、そのセッションシークレットを読み取ることもできます。そのシークレットを使うと、任意のテナントとしてサインインできてしまいます。
- この役割用に、キャッシュを無効にした Hyperdrive 設定を作成します。ダッシュボードはサインインや、記録したばかりのフォームを読み返します。サインアウト確認がキャッシュされていると、コピーされたセッションが使える状態のままになります。デプロイの前に毎回確認してください(
wrangler hyperdrive get <id>)。プライベートネットワーク上のデータベースには、そのネットワークに接続する Cloudflare Tunnel 上で Workers VPC 経由で接続します。VPC サービスの接続先には、Postgres 全体ではなく、TLS を使用し、そのデータベースとロールだけにアクセスできる PgBouncer など、Slotlock のデータベースだけを提供するリスナーを指定してください。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)- 2 つのレート制限バインディング。それぞれの
namespace_idはアカウント内で一意にします。
- 上記の手順で GitHub OAuth アプリを作成し、Worker のオリジン上のコールバック URL として
<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>を実行します。
-
各リクエストで専用のデータベースクライアントを開き、接続数を最大 5 つに制限して、レスポンスの送信後に閉じます。接続プールは Hyperdrive が維持します。
-
誰でもサインインを開始でき、コールバックごとにデータベースへの書き込みと、アプリのクライアントシークレットを使った GitHub へのリクエストが発生します。1 つのアイソレートで同時に実行できるのは最大 8 件ですが、Workers は多数のアイソレートを実行するため、2 つのレート制限バインディングで全体を制限します。
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 に設定してください。
クイックスタート:次の空き枠
空き状況の計算にデータベースは必要ありません。
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 スキーマ | リリースごとに 1 回、applySchema、applyTenantRls、grantApplicationRole を実行 |
| アプリケーション | なし(NOSUPERUSER NOBYPASSRLS) | すべてのトラフィック。強制行レベルセキュリティにより、1 つのテナントに制限 |
アプリケーションロールを 1 回作成し(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 にチェック項目が記載されています。applySchemaは、別のロールが管理するslotlockスキーマを拒否します(unsafe_slotlock_schema)。app.tenant_idなどのテナント設定がすでにありますか?createSlotlockStoreに{ tenantContextSetting: 'app.tenant_id' }を渡してください。
アプリケーションとして予約と読み取りを行う
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件、RDATE 値を2,000件、イベントには RDATE 値と EXDATE 値を合わせて20,000件含められます。プロパティの名前とパラメーターは最大8 KBで、リストは最大100件までです。SECONDLY から WEEKLY までの間隔、DURATION、またはイベントの長さは、最大でも1世紀です。必要な範囲がこれを超えるカレンダー(発生しないルール FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30 など)は拒否されます。書き込み時には invalid_event、フィードの場合は invalid_icalendar が返されます。
フィードのルールは開始時点から展開され、各シリーズは、フィード内の他のシリーズがどれだけの予算を使うかにかかわらず、処理中に最大4,000件(毎日のルールなら約11年分)の発生回を通過できます。保存済みのシリーズは、展開を最後に停止した位置を保持し、次の展開はそこから再開します。そのため、毎日のシリーズは継続する限り展開が進み続けます。予算の対象は期間であり、それ以前の年数ではありません。シリーズを変更する書き込みでは開始時点から検索し、検索が予算を超えた場合に限り、停止位置から再開します。位置情報が有効なのは、同じバージョンの ical.js で読み取られ、保存時と同じルール、開始時点、タイムゾーンを対象としている場合だけです。また、期間に到達する可能性のある発生回(イベントの長さとその前後1日の範囲内にあるもの、または例外によって期間内へ移動されるもの)より先を示すことはありません。それ以外の場合、シリーズは同じ予算の範囲内で開始時点から検索されます。メンテナンス中に保存済みのシリーズが予算を超えた場合、そのシリーズは refused としてカウントされ(ID は refusedEventIds に含まれます)、以前のカバー範囲が維持されます。
外部カレンダー
プロバイダーアダプター(Google、Microsoft、CalDAV)とその認証情報は、アプリケーション側に保持します。Slotlock は、それらとやり取りするための中立的な境界を提供します。
- Webhook の変更には
normalizeExternalCalendarChange、iCalendar の制限付き入出力にはparseICalendarChangesとemitICalendar、招待にはemitITipCalendarを使用します。 - 終日イベントは、カレンダーのタイムゾーンにおける丸一日として扱われます。タイムゾーンが設定されていないフィードは拒否されます。
- Intl が認識する TZID は、フィード内で定義されているかどうかにかかわらず、その IANA タイムゾーンとして読み取られます(そのような名前については、VTIMEZONE 独自のルールは使用されません)。フィード内でのみ定義されている TZID(Windows 名など)は、その VTIMEZONE から読み取られます。浮動時刻はカレンダーのタイムゾーンで読み取られます。そのため、UTC または別のタイムゾーンで記述された UNTIL、EXDATE、RDATE、RECURRENCE-ID は、該当する繰り返し発生を指定します。浮動 UNTIL は繰り返し予定独自の時刻基準で読み取られ、
RANGE=THISANDFUTUREの例外は、それ以降のすべての発生を例外独自のずれ分だけ移動します。 - 長さがないイベント(DTEND も DURATION もない DATE-TIME DTSTART)は、時間を占有しないため除外されます。PERIOD RDATE は、その期間の終了時刻まで続きます。ルートが VCALENDAR ではないドキュメント、または終了時刻が開始時刻より前のイベントは、
invalid_icalendarとして拒否されます。 - 繰り返し予定の DTSTART は、RRULE の有無にかかわらず最初の発生です。すべての RRULE が展開されます。2 つの RRULE、または RRULE と RDATE が同じ時刻を指定する場合、それらは 1 回の発生として扱われます。すべての EXDATE は、指定された発生を除外します(RFC 5545 §3.8.5)。時刻指定のある繰り返し予定に対する DATE EXDATE は、その日の繰り返し予定の開始時刻に発生するものを指定します。
- 発生の終了時刻は、カレンダーに表示される壁時計時刻に基づきます(時計を戻す夜の 00:30-02:30 は 3 時間続きます)。時計が進んで存在しない時刻に始まる発生は、代わりに繰り返し予定の正確な長さだけ続きます。この場合、時刻のギャップの前のオフセットから開始します(RFC 5545 §3.3.5)。
parseTrustedICalendarEventsはparseICalendarChangesと同じ方法で時刻を読み取るため、そこから保存された繰り返し予定は、フィードが報告する時間を占有します。recordCalendarCoverageは、フィードをどこまで読み取ったかを記録します。リソースが依存するソースをrequiredSourcesとして渡してください。カバレッジがcompleteに満たない場合、その時間が空いているとは証明されません。
他者が管理するフィードを読み取るには、同じパーサーをワーカースレッドで実行する createICalendarWorkerPool を使用します。フィードの処理がどれほど重くても、リクエストを処理するスレッドで ical.js が実行されることはありません。制限時間(デフォルトは 3 秒)またはヒープ(128 MB)を超えたワーカーは停止され、フィードは CalendarExpansionLimitError として拒否されます。すべてのワーカーが使用中で、キューもいっぱいの場合、プールは直ちに ICalendarWorkerBusyError を返します。ワーカーの起動には約 40 ms かかり、再利用されます。アイドル状態のワーカーによってプロセスが終了しなくなることはありません。プールはビルド済みのパッケージから実行されます(ワーカーは 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();
}
エージェントに Slotlock を公開する
1 つの 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 ヘッダーを送信しません。Web ページからではなく、バックエンドから呼び出してください。
エンドポイント
| メソッドとパス | 内容 |
|---|---|
POST {base}/mcp | Streamable HTTP による MCP、ステートレス |
POST {base}/a2a | A2A 1.0 JSON-RPC |
GET {base}/.well-known/agent-card.json | A2A エージェントカード。オリジンのルートにもあります |
GET {base}/manifest.json | プロトコルのバージョン、エンドポイント、およびすべてのツールの JSON スキーマ |
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 | 呼び出し元が作成したイベントを1件取得 | 読み取り専用 |
slotlock_create_event | 最大3,660日間のイベントを作成(冪等) | 書き込み |
slotlock_update_event | 期待されるリビジョンを指定してパッチを適用 | 書き込み |
slotlock_delete_event | 期待されるリビジョンを指定して論理削除 | 書き込み、破壊的 |
クエリ期間は最大367日です。各呼び出し元に表示されるのは自身が作成したイベントのみですが、全員の
イベントが予定ありの時間として扱われます。以前の名前である calendar.<verb> と calendar_<verb> も引き続き使用できますが、
現在は案内されません。
エラー
拒否は、モデルが対処できる結果として返されます。
- MCP:テキスト
{"error":{"code":"…"}}を含むisError: true。 - A2A:返信のデータ部分が
{"error":{"code":"…"}}。
| コード | 意味 |
|---|---|
invalid_arguments | 入力がスキーマに適合しない、または期間が逆転しているか367日を超えている |
forbidden | authorize が呼び出しを拒否した |
rate_limited | consumeRateLimit が呼び出しを拒否した |
not_found, resource_not_found, event_not_found | この呼び出し元には表示されない、またはそのようなIDがない:リソースIDは external_ref ではなく、slotlock_list_resources が返す id です |
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) をスローすると、エージェントにそのコードが渡されます。
それ以外の例外はサーバー障害となり、メッセージがサーバーの外に出ることはありません。エージェントには
internal_error が返され、操作の出力スキーマに適合しない結果の場合と同様に、onError(error, { operation }) にエラーが渡されるため、ログに記録できます。slotlock serve は標準エラー出力に
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 レート制限(すべての A2A エラーと同様に、200 エンベロープ内で RATE_LIMITED)、-32009 未対応の A2A-Version、-32003 プッシュ通知、-32004 ストリーミング、-32001 タスクが見つからない(サーバーはタスクを保持しません) |
認証と OAuth の検出
authenticate は、発行したベアラートークンを検証します。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 は引き続き、各トークンのオーディエンスを含めて検証する必要があります。
書き込み前に確認する
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 オフセットを付けます。エージェント自身のテキストは最後に引用して示します。 - 繰り返し予約では、予約回数、初回と最終回、および変更される各回を、ストアが予約に使用するものと同じ展開結果に基づいて示します。1回分への変更であっても、すべての繰り返しに適用されることを伝えます。エージェントは、毎日またはそれより間隔が長く、実際に発生し得る RFC 5545 ルールを送信できます。
- 保留中の質問は、呼び出し元、ツール、および正確な引数に対してシール(HMAC-SHA256)され、
ttlSeconds(600)後に期限切れになります。 - 質問を行えないクライアント(MCP 2025 の改訂版、A2A)には
confirmation_requiredが返され、何も書き込まれません。 secretsには32バイト以上のキーを1~8個設定します。ローテーションする場合は、新しいキーを先頭に追加してください。onEventは監査用に各結果を報告します。
カレンダーリソースとライブ更新
MCP 2026-07-28 では、各リソースが slotlock://resources/{id} という MCP リソースにもなります。このリソースを読み取ると、次の resourceWindowDays(30)日間の予定あり時間とカバー範囲が返されます。subscriptions/listen は、リソースの空き状況に変更があると notifications/resources/updated をストリーミングします。
- 変更は、
subscriptions.pollIntervalMs(10 s)ごとにバックエンドを通じて再読み込みすることで検出され、そのたびに呼び出し元を再確認します。 - サブスクリプションは、
maxDurationMs(15 minutes)経過時、shutdown()の実行時、またはアクセス権を失ったときに終了します。 - 1人の呼び出し元につき最大4件、サーバーごとに最大256件まで開けます(
maxPerPrincipal、maxTotal)。subscriptions: falseにすると無効になります。
キャッシュとトレース
2026-07-28 の結果にはキャッシュヒントが含まれます。ディスカバリーとツール一覧は 1 時間キャッシュされますが、空き状況はキャッシュされません。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 呼び出し、または実行前に拒否された呼び出し(JSON ではない本文(415、400)、大きすぎる本文(413)、オブジェクトではない本文(400))では、
{ type: 'rest', operation, status, durationMs, traceId? }が通知されます。 - 確認ごとに、その確認が判断する呼び出しの前に
{ type: 'confirmation', operation, outcome }が通知されます。 - 操作が読み取られる前に拒否されたリクエストは通知されません。たとえば、認証情報の欠落、レート制限、1.0 以外の
A2A-Version、解析できない JSON-RPC エンベロープ、どのルートにも一致しないパスが該当します。 traceIdは、_metaまたはヘッダーにある有効なtraceparentのトレース ID です。引数、呼び出し元、親スパン、tracestate、baggageは通知されません。また、例外をスローするonEventやonError、または拒否される Promise を返すものがあっても、クライアントから見える内容は変わりません。リソースの読み取りとサブスクリプションのポーリングは通知されません。
slotlock serve は、それぞれを JSON の 1 行("event":"call"、duration_ms、trace_id)として出力します。
REST と OpenAPI
rest: true を指定すると、各ツールをプレーン HTTP としても提供し、/openapi.json に OpenAPI 3.1 ドキュメントを公開します。slotlock serve では有効になっており、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 からは クライアント を使用してください。操作ごとに 1 つのメソッドがあり、アプリケーション API も含まれています。それぞれ CI でサーバーに対してテストされています。ほかの言語の場合は、任意の OpenAPI 3.1 ジェネレーターを使って
openapi.json(またはサーバーの/openapi.json)から生成してください。
アプリケーション API
エージェントを取り巻くアプリケーションが行う処理は、どの言語からでも利用できます。ストアのリソース、営業時間、ホールド、予約、カバレッジ、メンテナンスの呼び出しを、ツールと並ぶ REST API として提供します。admin スコープ(ダッシュボードでは「Full」)を持つキーが必要です。サーバー独自の SLOTLOCK_AUTH_TOKEN はエージェントに渡すトークンであり、読み取りと書き込みはできますが、admin にはなりません。これは 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が返されます。 - ライブラリを使う場合は、
rest: trueと並べてapplicationを渡します。{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }。scopesを持つプリンシパルにはadminが必要です。そのうえでauthorizeが判定します。
エージェントを接続する
MCP と A2A はワイヤープロトコルなので、どの言語のエージェントでも、独自の MCP または A2A クライアントを使って /mcp または /a2a に接続し、API キーをベアラートークンとして渡します。以下の例では TypeScript を使用します。エージェントを使わず 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)として返されます。Slotlock は、対応を求めるクライアントには MCP 2026-07-28 を、それ以外のクライアントには 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;
}
A2A-Version: 1.0 ヘッダーを付けて、application/json のデータパートを 1 つ送信します。形式は {"skill": "<id>", "arguments": {…}} です。エージェントカード内の各スキルには例があり、返信は同期的に返されます。
MCP Apps
読み取り専用ツールは ui://slotlock/calendar にリンクします。これは、サンドボックス内で MCP Apps(io.modelcontextprotocol/ui)がホストする、自己完結型のカレンダービューです。ネットワークリクエストは行わず、書き込みも行いません。
運用
すべてのテナントで、少なくとも毎日メンテナンスが必要です。メンテナンスでは、繰り返しイベントをローリング期間の上限まで延長し、保持期間を過ぎて不要になったデータを削除します。これにより、エージェントのクォータが回復します。
slotlock serveは、起動時にSLOTLOCK_TENANTをメンテナンスし、さらにキーを提供する各テナントを、その後は毎時、1 テナントずつメンテナンスします。件数を含むmaintenanceのログ行を出力します。slotlock maintainは、cron 用または一括インポート後に、1 つのテナントのメンテナンスをただちに実行します。管理者キーを使ってPOST /v1/maintenanceを呼び出すこともできます。- 各処理では、テナントごとの PostgreSQL アドバイザリロックを取得するため、サーバーと 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を指定しないと、後続のバッチでも毎回そのシリーズを調べることになります。 - クォータ。 各エージェントプリンシパルは、最大 1,000 件のイベント ID(有効なものとキャンセル済みのものを合わせた数)と 10,000 件の冪等性コマンドを保持できます。このうち、作成または更新に使えるのは 9,000 件までなので、キャンセル用の空きは常に確保されます。
createSlotlockStoreのagentOwnerEventQuotaとagentOwnerCommandQuotaで調整してください。pruneCalendarEventRetentionによってクォータが回復します。 - 保持期間。 コマンドは、既定では
SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS(30)日間の再実行ウィンドウにおける、完全一致のリプレイを証明する証拠です。削除処理では、まずすべての所有者の古いコマンドを削除し、次にウィンドウより古く、関連するコマンドが残っていないエージェント所有のトゥームストーンを削除します。internalトゥームストーン(プロバイダーおよび同期の権限)は、テナントを消去するまで保持されます。ウィンドウを過ぎると、再試行されたコマンドは期待されるリビジョンに基づいて新たに評価され、削除済みのエージェント ID は再び作成できます。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回です。 - 各エージェントに、最小限のスコープと有効期限を設定した専用の API キーを割り当ててください。
- デプロイのたびに、有効期限の短い管理者キーを使ってエンドツーエンドで動作確認し、その後キーを失効させてください。次のコマンドはヘルスチェック、検出、MCP、A2A、保留、予約(
slotlock-smokeという名前のリソースで400日先に作成し、その後キャンセル)、および 401、409、413、415 の拒否応答を確認します。SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs
サポート対象バージョン
| バージョン | |
|---|---|
| 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)、ユーザーへの通知、人間向けのカレンダーアプリは、この基盤を使って開発してください。
イベントには有限の期間が必要です(終日のイベントは現地時間で丸一日単位です。1つのイベントの長さは最大3,660日です)。終了日時のないエントリーは、推測せずに拒否します。
規範となるルールは SPEC.md に記載されています。ツールはパッケージコードを実行せずに
slotlock.manifest.json を読み取れます。脆弱性は SECURITY.md の説明に従って報告してください。プロジェクトの運営方法については、CONTRIBUTING.md
と GOVERNANCE.md をご覧ください。
ライセンス
Apache-2.0。Copyright 2026 TREFT LTD。依存関係に関する通知は NOTICE に記載されています。
コメント
AI アプリケーションが外部のツールやデータソースに統一的な方法で接続するためのオープンなプロトコルです。クライアントはモデルとの対話を担い、MCP サーバーはツール・リソース・プロンプトテンプレートを提供します。標準メッセージでやり取りするため、1 つのサーバーを複数のクライアントで再利用できます。