From 8767f19560adde04f36ebc53c2e7f120d6ceabad Mon Sep 17 00:00:00 2001 From: daylight55 Date: Thu, 1 Oct 2026 00:23:54 +0900 Subject: [PATCH 1/5] feat: add calendar creation without member invitations --- CHANGELOG.md | 8 +++ README.ja.md | 2 + README.md | 2 + package-lock.json | 121 ++++++++++++++++++++++-------------- src/client/api.ts | 18 ++++++ src/tools/calendar-tools.ts | 37 +++++++++++ src/tools/index.ts | 3 +- tests/api-client.test.mjs | 16 +++++ tests/tools.test.mjs | 1 + 9 files changed, 162 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b5539..01bd70a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Added `create_calendar` using the web API v2 calendar creation endpoint and existing CSRF authentication, without member invitations or automatic creation retries. + +### Security +- Refreshed locked dependencies to resolve high-severity audit findings. + ## [0.3.0] - 2026-05-25 ### Added diff --git a/README.ja.md b/README.ja.md index 7a13abe..5ed1073 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,6 +12,7 @@ Claude、Codex、Cursor、Windsurf などの MCP クライアントから、Time ### 機能 +- ➕ **カレンダー作成** - メンバーを招待せずに新しいカレンダーを作成 - 📅 **カレンダー一覧** - TimeTree のすべてのカレンダーを取得 - 📆 **イベント取得** - 自動ページネーションで任意のカレンダーのイベントを取得 - ➕ **イベント作成** - カレンダーに新しいイベントを追加 @@ -121,6 +122,7 @@ npm run build ### MCP ツール +- **create_calendar** - 名前(1〜20文字)と用途を指定してカレンダーを作成。メンバー招待は行いません - **list_calendars** - 参加ユーザー情報付きですべてのカレンダーを一覧表示 - **get_events** - 自動ページネーションでカレンダーのイベントを取得 - **get_updated_events** - 指定時刻以降に更新されたイベントを取得(効率的な差分同期) diff --git a/README.md b/README.md index 3ee7577..f789158 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Use Claude, Codex, Cursor, Windsurf, and other MCP clients to read and manage yo ### Features +- ➕ **Create Calendars** - Create a calendar without inviting members - 📅 **List Calendars** - Get all your TimeTree calendars - 📆 **Get Events** - Retrieve events from any calendar with automatic pagination - ➕ **Create Events** - Add new events to your calendars @@ -121,6 +122,7 @@ Then restart your MCP client. ### MCP Tools +- **create_calendar** - Create a calendar with a name (1–20 characters) and purpose; no invitations - **list_calendars** - List all calendars with participating users - **get_events** - Get events from a calendar with auto-pagination - **get_updated_events** - Get events updated after a specific timestamp (efficient incremental sync) diff --git a/package-lock.json b/package-lock.json index 6f6a0b2..0eb49e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -467,9 +467,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -991,20 +991,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1014,6 +1014,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1266,9 +1279,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1423,9 +1436,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -1593,9 +1606,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1605,9 +1618,9 @@ } }, "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "version": "4.13.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", + "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1656,9 +1669,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" @@ -2036,12 +2049,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -2247,14 +2261,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -2266,13 +2280,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -2487,17 +2501,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { diff --git a/src/client/api.ts b/src/client/api.ts index 305fb25..8726cec 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -32,6 +32,7 @@ import type { } from '../types/timetree.js'; import { CalendarLabelsResponseSchema, + CalendarSchema, CalendarUsersResponseSchema, CalendarVirtualUsersResponseSchema, CalendarsResponseSchema, @@ -129,6 +130,23 @@ export class TimeTreeAPIClient { } } + /** Create a calendar owned by the authenticated user, without invitations. + * Uses the current web application's POST /api/v2/calendars contract. + * Never automatically retries a non-idempotent creation request. + */ + async createCalendar(name: string, purpose: string = 'lover'): Promise { + if (!name.trim() || name.trim().length > 20) { + throw new TimeTreeAPIError('Calendar name must contain 1 to 20 characters', 400); + } + await this.ensureAuthenticated(); + this.authManager.getCsrfToken(); + const response = await this.authManager.getHttpClient().post<{ calendar: unknown }>( + `${TIMETREE_CONFIG.V2_BASE_URL}/calendars`, + { name: name.trim(), purpose }, undefined, true + ); + return CalendarSchema.parse(response.calendar); + } + /** Recursively sync events from a calendar with automatic pagination. */ private async syncEvents( calendarId: string, diff --git a/src/tools/calendar-tools.ts b/src/tools/calendar-tools.ts index 3be8a90..84337b9 100644 --- a/src/tools/calendar-tools.ts +++ b/src/tools/calendar-tools.ts @@ -80,3 +80,40 @@ export function createListCalendarsTool(apiClient: TimeTreeAPIClient) { }, }; } + +export const CreateCalendarInputSchema = z.object({ + name: z.string().trim().min(1).max(20), + purpose: z.enum(['family', 'private', 'lover', 'work', 'friend', 'work_schedule', + 'lesson', 'school_event', 'circle', 'hobby', 'other']).default('lover'), +}).strict(); + +export function createCreateCalendarTool(apiClient: TimeTreeAPIClient) { + return { + name: 'create_calendar', + description: 'Create a calendar owned by the authenticated user. Does not invite members or change existing calendars. Creation is not automatically retried.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 20 }, + purpose: { type: 'string', enum: ['family', 'private', 'lover', 'work', 'friend', + 'work_schedule', 'lesson', 'school_event', 'circle', 'hobby', 'other'], default: 'lover' }, + }, + required: ['name'], + additionalProperties: false, + }, + handler: async (args: unknown) => { + try { + const input = CreateCalendarInputSchema.parse(args); + const calendar = await apiClient.createCalendar(input.name, input.purpose); + return { content: [{ type: 'text', text: JSON.stringify({ + id: String(calendar.id), name: calendar.name, alias_code: calendar.alias_code ?? null, + }) }] }; + } catch { + // Avoid forwarding response bodies or submitted private data to MCP logs. + return { content: [{ type: 'text', text: JSON.stringify({ + error: 'Calendar creation failed. Check existing calendars before retrying.', + }) }], isError: true }; + } + }, + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e9329ec..8142a0f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,7 +4,7 @@ */ import type { TimeTreeAPIClient } from '../client/api.js'; -import { createListCalendarsTool } from './calendar-tools.js'; +import { createCreateCalendarTool, createListCalendarsTool } from './calendar-tools.js'; import { createGetCalendarLabelsTool, createGetCalendarMembersTool, @@ -33,6 +33,7 @@ import { export function registerTools(apiClient: TimeTreeAPIClient) { return [ createListCalendarsTool(apiClient), + createCreateCalendarTool(apiClient), createGetEventsTool(apiClient), createGetUpdatedEventsTool(apiClient), createCreateEventTool(apiClient), diff --git a/tests/api-client.test.mjs b/tests/api-client.test.mjs index de6cac0..4c151e7 100644 --- a/tests/api-client.test.mjs +++ b/tests/api-client.test.mjs @@ -28,6 +28,7 @@ function makeClient(http) { throw new Error('authenticate should not be called in unit tests'); }, getHttpClient: () => http, + getCsrfToken: () => "test-token", }); } @@ -166,3 +167,18 @@ test('event comment methods use activity endpoints and filter comment activities assert.ok(calls.some((call) => call.url === 'https://timetreeapp.com/api/v1/calendar/123/event/evt/activities')); assert.ok(calls.some((call) => call.url === 'https://timetreeapp.com/api/v1/calendar/123/event/evt/activity/comment')); }); + +test('createCalendar uses v2 CSRF-protected creation without invitations or retries', async () => { + const calls = []; + const client = makeClient({post: async (...args) => { + calls.push(args); + return { calendar: {id: 456, name: 'Private', alias_code: 'new-alias'} }; + }}); + assert.equal((await client.createCalendar(' Private ', 'lover')).id, 456); + assert.deepEqual(calls, [['https://timetreeapp.com/api/v2/calendars', + {name: 'Private', purpose: 'lover'}, undefined, true]]); + let count = 0; + const failing = makeClient({post: async () => {count++; throw new Error('timeout');}}); + await assert.rejects(failing.createCalendar('Private')); + assert.equal(count, 1); +}); diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index 550da8c..8454741 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -36,6 +36,7 @@ test('registerTools exposes baseline plus Wave 1 tools', () => { assert.deepEqual(names, [ 'add_event_comment', + 'create_calendar', 'create_event', 'create_memo', 'delete_event', From 44630cf1a63a595269f527d40fd8b5f522b7d3f7 Mon Sep 17 00:00:00 2001 From: EunHyunsu Date: Thu, 1 Oct 2026 20:47:24 +0900 Subject: [PATCH 2/5] docs: sync create_calendar docs across READMEs Add the missing Korean README entries, use a distinct emoji from Create Events, list calendar creation in the project guide, and drop the Security changelog entry now that #36 ships the lockfile refresh. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 --- CLAUDE.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 ++ README.md | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bd70a..d1f2f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `create_calendar` using the web API v2 calendar creation endpoint and existing CSRF authentication, without member invitations or automatic creation retries. -### Security -- Refreshed locked dependencies to resolve high-severity audit findings. - ## [0.3.0] - 2026-05-25 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0571299..1b4d018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Important context: The server currently exposes tools for: -- Listing calendars. +- Listing and creating calendars (creation does not invite members). - Reading calendar events and updated events. - Creating, updating, and deleting events. - Managing event comments. diff --git a/README.ja.md b/README.ja.md index 5ed1073..99a696a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ Claude、Codex、Cursor、Windsurf などの MCP クライアントから、Time ### 機能 -- ➕ **カレンダー作成** - メンバーを招待せずに新しいカレンダーを作成 +- 🗓️ **カレンダー作成** - メンバーを招待せずに新しいカレンダーを作成 - 📅 **カレンダー一覧** - TimeTree のすべてのカレンダーを取得 - 📆 **イベント取得** - 自動ページネーションで任意のカレンダーのイベントを取得 - ➕ **イベント作成** - カレンダーに新しいイベントを追加 diff --git a/README.ko.md b/README.ko.md index 0c7bce1..878d2d5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -12,6 +12,7 @@ Claude, Codex, Cursor, Windsurf 같은 MCP 클라이언트에서 TimeTree 캘린 ### 기능 +- 🗓️ **캘린더 생성** - 멤버를 초대하지 않고 새 캘린더 만들기 - 📅 **캘린더 목록** - 모든 TimeTree 캘린더 가져오기 - 📆 **이벤트 조회** - 자동 페이지네이션으로 캘린더 이벤트 조회 - ➕ **이벤트 생성** - 캘린더에 새 이벤트 추가 @@ -121,6 +122,7 @@ npm run build ### MCP 도구 +- **create_calendar** - 이름(1~20자)과 용도를 지정해 캘린더를 생성합니다. 멤버는 초대하지 않습니다 - **list_calendars** - 참여 중인 사용자와 함께 모든 캘린더 조회 - **get_events** - 자동 페이지네이션으로 캘린더 이벤트 조회 - **get_updated_events** - 특정 시간 이후 업데이트된 이벤트 조회 (효율적인 증분 동기화) diff --git a/README.md b/README.md index f789158..7d24e05 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Use Claude, Codex, Cursor, Windsurf, and other MCP clients to read and manage yo ### Features -- ➕ **Create Calendars** - Create a calendar without inviting members +- 🗓️ **Create Calendars** - Create a calendar without inviting members - 📅 **List Calendars** - Get all your TimeTree calendars - 📆 **Get Events** - Retrieve events from any calendar with automatic pagination - ➕ **Create Events** - Add new events to your calendars From 5cbffec92e063a2c2f57aacdfe2632bebf6b1db9 Mon Sep 17 00:00:00 2001 From: daylight55 Date: Wed, 7 Oct 2026 17:01:21 +0900 Subject: [PATCH 3/5] fix: address calendar creation review feedback --- CHANGELOG.md | 2 +- COMMANDS.md | 13 +++++++++++++ README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- package-lock.json | 16 ++++++++++------ src/client/api.ts | 27 +++++++++++++++++---------- src/tools/calendar-tools.ts | 33 +++++++++++++++++++++++++++------ tests/api-client.test.mjs | 30 ++++++++++++++++++++++++++++-- tests/tools.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 10 files changed, 135 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b09e53..c42ea82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added `create_calendar` using the web API v2 calendar creation endpoint and existing CSRF authentication, without member invitations or automatic creation retries. +- Added `create_calendar` using the web API v2 calendar creation endpoint and existing CSRF authentication, without member invitations. Both name and purpose are required; requests use rate limiting and retry only HTTP 429. Input and authentication errors are distinguishable without forwarding upstream details. - Added `get_holidays` for public holidays and memorial days by country and date range. - Added `start_before`, `query`, `label_id`, and `include_memos` filters to `get_events`. - `get_events` expands recurring events into their occurrences when `start_before` is set (`expand_recurring`, default on). EXDATE exceptions are honored. diff --git a/COMMANDS.md b/COMMANDS.md index 6c18f35..4abfb37 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -284,3 +284,16 @@ Events can be color-coded with `label_id` 1-10: - **All-day events**: TimeTree uses inclusive end dates. A Feb 15-16 event sets `end_at` to Feb 16 00:00, not Feb 17. - **Timezones**: Default is UTC. Specify timezone for accurate local times. - **Write operations** (create/update/delete) require a CSRF token, which is managed automatically. + +### Create a calendar + +Call `create_calendar` with both `name` (1–20 characters) and an explicit `purpose`: + +```json +{"name": "Project calendar", "purpose": "work"} +``` + +Supported purposes: `family`, `private`, `lover`, `work`, `friend`, `work_schedule`, `lesson`, `school_event`, `circle`, `hobby`, `other`. +Creation does not invite members. Requests use rate limiting and retry only HTTP 429 rejections. +Invalid input returns field-level reasons without input values. Authentication errors return `Authentication failed`. +After an uncertain creation failure (such as a timeout), check existing calendars before retrying. diff --git a/README.ja.md b/README.ja.md index d68c68b..bab0f87 100644 --- a/README.ja.md +++ b/README.ja.md @@ -124,7 +124,7 @@ npm run build ### MCP ツール -- **create_calendar** - 名前(1〜20文字)と用途を指定してカレンダーを作成。メンバー招待は行いません +- **create_calendar** - 名前(1〜20文字)と用途(必須)を指定してカレンダーを作成。メンバー招待は行わず、HTTP 429 の場合のみ再試行します - **list_calendars** - 参加ユーザー情報付きですべてのカレンダーを一覧表示 - **get_events** - 自動ページネーションでカレンダーのイベントを取得(開始時刻順、キーワード/ラベル/期間フィルター、期間内の繰り返し予定を展開) - **get_updated_events** - 指定時刻以降に更新されたイベントを取得(効率的な差分同期) diff --git a/README.ko.md b/README.ko.md index d85294b..61ab8e2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -124,7 +124,7 @@ npm run build ### MCP 도구 -- **create_calendar** - 이름(1~20자)과 용도를 지정해 캘린더를 생성합니다. 멤버는 초대하지 않습니다 +- **create_calendar** - 이름(1~20자)과 필수 용도를 지정해 캘린더를 생성합니다. 멤버는 초대하지 않으며 HTTP 429만 재시도합니다 - **list_calendars** - 참여 중인 사용자와 함께 모든 캘린더 조회 - **get_events** - 자동 페이지네이션으로 캘린더 이벤트 조회 (시작 시간순 정렬, 키워드/라벨/기간 필터, 기간 내 반복 일정 펼침) - **get_updated_events** - 특정 시간 이후 업데이트된 이벤트 조회 (효율적인 증분 동기화) diff --git a/README.md b/README.md index dd40eac..b662045 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Then restart your MCP client. ### MCP Tools -- **create_calendar** - Create a calendar with a name (1–20 characters) and purpose; no invitations +- **create_calendar** - Create a calendar with a name (1–20 characters) and a required, explicit purpose; no invitations. Only HTTP 429 requests are retried - **list_calendars** - List all calendars with participating users - **get_events** - Get events from a calendar with auto-pagination, sorted by start time, with keyword/label/date filters and recurring occurrences expanded - **get_updated_events** - Get events updated after a specific timestamp (efficient incremental sync) diff --git a/package-lock.json b/package-lock.json index f54a1b0..90d682e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -518,9 +518,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", - "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -2036,9 +2036,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -2046,6 +2046,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { diff --git a/src/client/api.ts b/src/client/api.ts index 56cfdff..2e602b0 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -173,21 +173,28 @@ export class TimeTreeAPIClient { } } - /** Create a calendar owned by the authenticated user, without invitations. - * Uses the current web application's POST /api/v2/calendars contract. - * Never automatically retries a non-idempotent creation request. - */ - async createCalendar(name: string, purpose: string = 'lover'): Promise { + /** Create a calendar without invitations. Retries only rejected (429) requests. */ + async createCalendar(name: string, purpose: string): Promise { if (!name.trim() || name.trim().length > 20) { throw new TimeTreeAPIError('Calendar name must contain 1 to 20 characters', 400); } await this.ensureAuthenticated(); + // Precondition check: writes require a CSRF token before sending the request. this.authManager.getCsrfToken(); - const response = await this.authManager.getHttpClient().post<{ calendar: unknown }>( - `${TIMETREE_CONFIG.V2_BASE_URL}/calendars`, - { name: name.trim(), purpose }, undefined, true - ); - return CalendarSchema.parse(response.calendar); + try { + const response = await this.rateLimiter.executeWithRetry(() => + this.authManager.getHttpClient().post<{ calendar: unknown }>( + `${TIMETREE_CONFIG.V2_BASE_URL}/calendars`, + { name: name.trim(), purpose }, undefined, true + ) + ); + return CalendarSchema.parse(response.calendar); + } catch (error) { + if (getStatusCode(error) === 403) { + throw new TimeTreeAPIError('CSRF token missing or invalid - re-authentication required', 403); + } + throw error; + } } /** Recursively sync events from a calendar with automatic pagination. */ diff --git a/src/tools/calendar-tools.ts b/src/tools/calendar-tools.ts index 84337b9..571116b 100644 --- a/src/tools/calendar-tools.ts +++ b/src/tools/calendar-tools.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import type { TimeTreeAPIClient } from '../client/api.js'; +import { AuthenticationError } from '../client/auth.js'; import { logger } from '../utils/logger.js'; export const ListCalendarsInputSchema = z.object({}); @@ -84,21 +85,21 @@ export function createListCalendarsTool(apiClient: TimeTreeAPIClient) { export const CreateCalendarInputSchema = z.object({ name: z.string().trim().min(1).max(20), purpose: z.enum(['family', 'private', 'lover', 'work', 'friend', 'work_schedule', - 'lesson', 'school_event', 'circle', 'hobby', 'other']).default('lover'), + 'lesson', 'school_event', 'circle', 'hobby', 'other']), }).strict(); export function createCreateCalendarTool(apiClient: TimeTreeAPIClient) { return { name: 'create_calendar', - description: 'Create a calendar owned by the authenticated user. Does not invite members or change existing calendars. Creation is not automatically retried.', + description: 'Create a calendar owned by the authenticated user. Does not invite members or change existing calendars. Purpose is required; only rate-limited (429) requests are retried.', inputSchema: { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 20 }, purpose: { type: 'string', enum: ['family', 'private', 'lover', 'work', 'friend', - 'work_schedule', 'lesson', 'school_event', 'circle', 'hobby', 'other'], default: 'lover' }, + 'work_schedule', 'lesson', 'school_event', 'circle', 'hobby', 'other'] }, }, - required: ['name'], + required: ['name', 'purpose'], additionalProperties: false, }, handler: async (args: unknown) => { @@ -108,8 +109,28 @@ export function createCreateCalendarTool(apiClient: TimeTreeAPIClient) { return { content: [{ type: 'text', text: JSON.stringify({ id: String(calendar.id), name: calendar.name, alias_code: calendar.alias_code ?? null, }) }] }; - } catch { - // Avoid forwarding response bodies or submitted private data to MCP logs. + } catch (error) { + if (error instanceof z.ZodError) { + const issues = error.issues.map((issue) => ({ + field: issue.path[0] === 'name' || issue.path[0] === 'purpose' ? issue.path[0] : 'input', + reason: issue.code === 'too_small' ? 'Must contain at least 1 character' + : issue.code === 'too_big' ? 'Must contain at most 20 characters' + : issue.code === 'invalid_enum_value' ? 'Choose a supported calendar purpose' + : issue.code === 'unrecognized_keys' ? 'Only name and purpose are allowed' + : 'Required field with a supported type and value', + })); + return { content: [{ type: 'text', text: JSON.stringify({ + error: 'Invalid calendar input', issues, + }) }], isError: true }; + } + const statusCode = error && typeof error === 'object' && 'statusCode' in error + ? error.statusCode : undefined; + if (error instanceof AuthenticationError || statusCode === 401 || statusCode === 403) { + return { content: [{ type: 'text', text: JSON.stringify({ + error: 'Authentication failed', + }) }], isError: true }; + } + logger.error('Tool: create_calendar failed', { error }); return { content: [{ type: 'text', text: JSON.stringify({ error: 'Calendar creation failed. Check existing calendars before retrying.', }) }], isError: true }; diff --git a/tests/api-client.test.mjs b/tests/api-client.test.mjs index 99b254a..3b2fdd5 100644 --- a/tests/api-client.test.mjs +++ b/tests/api-client.test.mjs @@ -168,18 +168,22 @@ test('event comment methods use activity endpoints and filter comment activities assert.ok(calls.some((call) => call.url === 'https://timetreeapp.com/api/v1/calendar/123/event/evt/activity/comment')); }); -test('createCalendar uses v2 CSRF-protected creation without invitations or retries', async () => { +test('createCalendar uses v2 CSRF-protected creation without invitations, with rate limiting', async () => { const calls = []; const client = makeClient({post: async (...args) => { calls.push(args); return { calendar: {id: 456, name: 'Private', alias_code: 'new-alias'} }; }}); + let throttled = 0; + const retry = client.rateLimiter.executeWithRetry.bind(client.rateLimiter); + client.rateLimiter.executeWithRetry = (...args) => { throttled++; return retry(...args); }; assert.equal((await client.createCalendar(' Private ', 'lover')).id, 456); + assert.equal(throttled, 1); assert.deepEqual(calls, [['https://timetreeapp.com/api/v2/calendars', {name: 'Private', purpose: 'lover'}, undefined, true]]); let count = 0; const failing = makeClient({post: async () => {count++; throw new Error('timeout');}}); - await assert.rejects(failing.createCalendar('Private')); + await assert.rejects(failing.createCalendar('Private', 'private')); assert.equal(count, 1); }); @@ -285,3 +289,25 @@ test('getLatestEventActivities queries the latest activity feed for calendars', assert.deepEqual(url.searchParams.getAll('calendar_ids[]'), ['123']); assert.deepEqual(events[0].activities[0].status, [1]); }); + +test('createCalendar retries a rejected 429 request but not timeouts or 5xx', async () => { + let count = 0; + const limited = makeClient({ post: async () => { + if (++count === 1) throw Object.assign(new Error('rate limited'), {statusCode: 429}); + return {calendar: {id: 456, name: 'Sample', alias_code: 'sample'}}; + }}); + await limited.createCalendar('Sample', 'work'); + assert.equal(count, 2); + for (const error of [new Error('timeout'), Object.assign(new Error('server failure'), {statusCode: 500})]) { + let requests = 0; + const client = makeClient({post: async () => {requests++; throw error;}}); + await assert.rejects(client.createCalendar('Sample', 'work')); + assert.equal(requests, 1); + } +}); + +test('createCalendar maps 403 without leaking the upstream error', async () => { + const client = makeClient({post: async () => {throw Object.assign(new Error('upstream detail'), {statusCode: 403});}}); + await assert.rejects(client.createCalendar('Sample', 'work'), error => + error.name === 'TimeTreeAPIError' && error.statusCode === 403 && !error.message.includes('upstream detail')); +}); diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index 1ae5222..9599fca 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -347,3 +347,39 @@ test('get_recent_activity names status codes, joins member names, and filters by ['memo-1', 'memo', true], ]); }); + +test('create_calendar requires explicit purpose and returns only documented fields', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const calls = []; + const tool = createCreateCalendarTool({createCalendar: async (...args) => { + calls.push(args); return {id: 123, name: 'Sample', alias_code: 'sample', extra: 'hidden'}; + }}); + assert.deepEqual(tool.inputSchema.required, ['name', 'purpose']); + assert.equal('default' in tool.inputSchema.properties.purpose, false); + assert.deepEqual(parseToolText(await tool.handler({name: 'Sample', purpose: 'work'})), + {id: '123', name: 'Sample', alias_code: 'sample'}); + for (const args of [{name: 'Sample'}, {name: 'x'.repeat(21), purpose: 'work'}, + {name: 'Sample', purpose: 'unsupported-input-value'}, {name: 'Sample', purpose: 'work', extra: 'hidden'}]) { + const result = await tool.handler(args); + assert.equal(result.isError, true); + assert.equal(parseToolText(result).error, 'Invalid calendar input'); + assert.ok(parseToolText(result).issues.length); + assert.equal(JSON.stringify(result).includes('unsupported-input-value'), false); + } + assert.equal(calls.length, 1); +}); + +test('create_calendar distinguishes auth and uncertain failures without forwarding details', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const {AuthenticationError} = await import('../.test-dist/client/auth.js'); + for (const error of [new AuthenticationError('upstream detail'), + Object.assign(new Error('upstream detail'), {statusCode: 401}), + Object.assign(new Error('upstream detail'), {statusCode: 403}), new Error('upstream detail')]) { + const tool = createCreateCalendarTool({createCalendar: async () => {throw error;}}); + const result = await tool.handler({name: 'Sample', purpose: 'work'}); + assert.equal(result.isError, true); + assert.equal(JSON.stringify(result).includes('upstream detail'), false); + assert.equal(parseToolText(result).error, error instanceof AuthenticationError || error.statusCode === 401 || error.statusCode === 403 + ? 'Authentication failed' : 'Calendar creation failed. Check existing calendars before retrying.'); + } +}); From 974c7bf50be99d642ac87d04989e60524427ad62 Mon Sep 17 00:00:00 2001 From: daylight55 Date: Wed, 7 Oct 2026 17:03:52 +0900 Subject: [PATCH 4/5] fix: include calendar creation in desktop extension manifest --- mcpb/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/mcpb/manifest.json b/mcpb/manifest.json index c6d9e7c..7637dd9 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -32,6 +32,7 @@ }, "tools": [ { "name": "list_calendars", "description": "List your TimeTree calendars." }, + { "name": "create_calendar", "description": "Create a calendar with an explicit name and purpose, without inviting members." }, { "name": "get_events", "description": "Read events with date, keyword, and label filters; expands recurring events." }, { "name": "get_updated_events", "description": "Find events changed after a point in time." }, { "name": "create_event", "description": "Create an event." }, From 4eb58defb12cf20a5fa41a7c4240805e0cd0ffe0 Mon Sep 17 00:00:00 2001 From: daylight55 Date: Wed, 7 Oct 2026 18:28:38 +0900 Subject: [PATCH 5/5] fix: harden calendar creation failure handling --- CHANGELOG.md | 4 +++ COMMANDS.md | 3 ++- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 1 + src/client/api.ts | 10 ++++++-- src/tools/calendar-tools.ts | 31 ++++++++++++----------- tests/api-client.test.mjs | 42 ++++++++++++++++++++++++++++++- tests/tools.test.mjs | 50 +++++++++++++++++++++++++++++++++++++ 9 files changed, 124 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2516955..2454302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `create_calendar` without member invitations, requiring an explicit name and purpose. Uses existing rate limiting and authentication recovery without retrying timeouts or server errors. - Calendar creation distinguishes invalid fields and authentication failures without forwarding upstream details. +### Fixed +- Calendar creation treats invalid success responses as uncertain outcomes instead of invalid user input, and reports persistent TimeTree session or CSRF rejections as authentication failures. +- Authentication recovery no longer retries HTTP 5xx responses, even when their bodies contain session or CSRF error codes. + ### Security - Updated locked MCP SDK and proxy-addr dependencies to resolve high and critical audit findings. diff --git a/COMMANDS.md b/COMMANDS.md index df21654..3e1f632 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -303,4 +303,5 @@ Call `create_calendar` with both `name` (1–20 characters) and an explicit `pur Supported purposes: `family`, `private`, `lover`, `work`, `friend`, `work_schedule`, `lesson`, `school_event`, `circle`, `hobby`, `other`. Creation does not invite members. Requests use rate limiting and retry HTTP 429 rejections or rejected authentication through the shared client. Timeouts and server errors are not retried. Invalid input returns field-level reasons without input values. Authentication errors return `Authentication failed`. -After an uncertain creation failure (such as a timeout), check existing calendars before retrying. +Persistent TimeTree session or CSRF rejections are reported as authentication failures after one sign-in recovery attempt. +After an uncertain creation failure (such as a timeout or an unexpected success response), check existing calendars before retrying. Response validation failures are not reported as invalid input. HTTP 5xx responses are never retried, even if their bodies contain authentication error codes. diff --git a/README.ja.md b/README.ja.md index b25bb0e..fe8503f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -121,7 +121,7 @@ GUI クライアントが `node` を見つけられない場合は、`command -v | その他 | `get_holidays`, `get_recent_activity` | `create_calendar` は名前(1〜20文字)と用途の明示を必須とします。メンバー招待は行わず、タイムアウト・サーバーエラーでは再試行しません。 - +作成後の応答を検証できない場合は、再実行前に既存のカレンダーを確認してください。 パラメータと使用例: [COMMANDS.md](COMMANDS.md) diff --git a/README.ko.md b/README.ko.md index 1ea96c0..bc8929e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -121,7 +121,7 @@ GUI 클라이언트가 `node`를 찾지 못하면 `command -v node`로 나온 | 기타 | `get_holidays`, `get_recent_activity` | `create_calendar`에는 이름(1~20자)과 명시적인 용도가 필수입니다. 멤버를 초대하지 않으며 시간 초과나 서버 오류는 재시도하지 않습니다. - +생성 응답을 검증할 수 없으면 다시 시도하기 전에 기존 캘린더를 확인하세요. 파라미터와 사용 예시: [COMMANDS.md](COMMANDS.md) diff --git a/README.md b/README.md index fd6ddfd..0b6b9ea 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ Details: [docs/UPDATING.md](docs/UPDATING.md). Changes: [CHANGELOG.md](CHANGELOG Parameters and examples: [COMMANDS.md](COMMANDS.md) `create_calendar` requires a name (1–20 characters) and an explicit purpose. It does not invite members. Timeouts and server errors are not retried. +If the creation response cannot be validated, check existing calendars before trying again. ## Privacy and security diff --git a/src/client/api.ts b/src/client/api.ts index 5aab156..3648d64 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'crypto'; import { TIMETREE_CONFIG } from '../config/config.js'; import { RateLimiter } from '../utils/rate-limiter.js'; import { logger } from '../utils/logger.js'; -import { TimeTreeAuthManager } from './auth.js'; +import { AuthenticationError, TimeTreeAuthManager } from './auth.js'; import type { Calendar, CalendarLabel, @@ -130,7 +130,10 @@ function isRequestShapeRejected(error: unknown): boolean { /** Signing in again fetches a fresh session and CSRF token, so either rejection is recoverable. */ function needsReauthentication(error: unknown): boolean { - if (getStatusCode(error) === 401) return true; + const statusCode = getStatusCode(error); + // A server error leaves write outcomes uncertain, even when its body contains an auth code. + if (statusCode !== undefined && statusCode >= 500) return false; + if (statusCode === 401) return true; const code = getTimeTreeErrorCode(error); return code === TIMETREE_ERROR.SESSION_REJECTED || code === TIMETREE_ERROR.CSRF_REJECTED; } @@ -279,6 +282,9 @@ export class TimeTreeAPIClient { if (getStatusCode(error) === 403) { throw new TimeTreeAPIError('CSRF token missing or invalid - re-authentication required', 403); } + if (needsReauthentication(error)) { + throw new AuthenticationError('Session or CSRF token rejected after re-authentication', getStatusCode(error)); + } throw error; } } diff --git a/src/tools/calendar-tools.ts b/src/tools/calendar-tools.ts index 8dc9493..e926efd 100644 --- a/src/tools/calendar-tools.ts +++ b/src/tools/calendar-tools.ts @@ -103,26 +103,27 @@ export function createCreateCalendarTool(apiClient: TimeTreeAPIClient) { additionalProperties: false, }, handler: async (args: unknown) => { + const parsed = CreateCalendarInputSchema.safeParse(args); + if (!parsed.success) { + const issues = parsed.error.issues.map((issue) => ({ + field: issue.path[0] === 'name' || issue.path[0] === 'purpose' ? issue.path[0] : 'input', + reason: issue.code === 'too_small' ? 'Must contain at least 1 character' + : issue.code === 'too_big' ? 'Must contain at most 20 characters' + : issue.code === 'invalid_enum_value' ? 'Choose a supported calendar purpose' + : issue.code === 'unrecognized_keys' ? 'Only name and purpose are allowed' + : 'Required field with a supported type and value', + })); + return { content: [{ type: 'text', text: JSON.stringify({ + error: 'Invalid calendar input', issues, + }) }], isError: true }; + } + // Response validation can fail after the calendar was created; keep it out of input errors. try { - const input = CreateCalendarInputSchema.parse(args); - const calendar = await apiClient.createCalendar(input.name, input.purpose); + const calendar = await apiClient.createCalendar(parsed.data.name, parsed.data.purpose); return { content: [{ type: 'text', text: JSON.stringify({ id: String(calendar.id), name: calendar.name, alias_code: calendar.alias_code ?? null, }) }] }; } catch (error) { - if (error instanceof z.ZodError) { - const issues = error.issues.map((issue) => ({ - field: issue.path[0] === 'name' || issue.path[0] === 'purpose' ? issue.path[0] : 'input', - reason: issue.code === 'too_small' ? 'Must contain at least 1 character' - : issue.code === 'too_big' ? 'Must contain at most 20 characters' - : issue.code === 'invalid_enum_value' ? 'Choose a supported calendar purpose' - : issue.code === 'unrecognized_keys' ? 'Only name and purpose are allowed' - : 'Required field with a supported type and value', - })); - return { content: [{ type: 'text', text: JSON.stringify({ - error: 'Invalid calendar input', issues, - }) }], isError: true }; - } const statusCode = error && typeof error === 'object' && 'statusCode' in error ? error.statusCode : undefined; if (error instanceof AuthenticationError || statusCode === 401 || statusCode === 403) { diff --git a/tests/api-client.test.mjs b/tests/api-client.test.mjs index 6d5dc61..5e46cf5 100644 --- a/tests/api-client.test.mjs +++ b/tests/api-client.test.mjs @@ -21,7 +21,7 @@ function makeEvent(overrides = {}) { }; } -function makeClient(http) { +function makeClient(http, auth = {}) { return new TimeTreeAPIClient({ isAuthenticated: () => true, authenticate: async () => { @@ -29,6 +29,7 @@ function makeClient(http) { }, getHttpClient: () => http, getCsrfToken: () => "test-token", + ...auth, }); } @@ -309,3 +310,42 @@ test('createCalendar maps 403 without leaking the upstream error', async () => { await assert.rejects(client.createCalendar('Sample', 'work'), error => error.name === 'TimeTreeAPIError' && error.statusCode === 403 && !error.message.includes('upstream detail')); }); + +test('createCalendar never retries 5xx carrying session or CSRF error codes', async () => { + for (const statusCode of [500, 502, 503, 504]) { + for (const code of [-493, -1]) { + let requests = 0; + let signIns = 0; + const error = Object.assign(new Error('synthetic server failure'), { + statusCode, response: JSON.stringify({error: {code}}), + }); + const client = makeClient({post: async () => { + requests++; + if (requests === 1) throw error; + return {calendar: {id: 456, name: 'Sample'}}; + }}, {authenticate: async () => {signIns++;}}); + + await assert.rejects(client.createCalendar('Sample', 'work'), thrown => thrown === error); + assert.equal(requests, 1, `HTTP ${statusCode}, code ${code} was retried`); + assert.equal(signIns, 0); + } + } +}); + +test('createCalendar still recovers rejected authentication once', async () => { + for (const [statusCode, code] of [[401, undefined], [400, -493], [422, -1]]) { + let requests = 0; + let signIns = 0; + const client = makeClient({post: async () => { + requests++; + if (requests === 1) throw Object.assign(new Error('synthetic auth rejection'), { + statusCode, response: JSON.stringify({error: {code}}), + }); + return {calendar: {id: 456, name: 'Sample'}}; + }}, {authenticate: async () => {signIns++;}}); + + assert.equal((await client.createCalendar('Sample', 'work')).id, 456); + assert.equal(requests, 2); + assert.equal(signIns, 1); + } +}); diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index 9599fca..df50701 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -383,3 +383,53 @@ test('create_calendar distinguishes auth and uncertain failures without forwardi ? 'Authentication failed' : 'Calendar creation failed. Check existing calendars before retrying.'); } }); + +test('create_calendar treats response validation errors as uncertain creation outcomes', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const {TimeTreeAPIClient} = await import('../.test-dist/client/api.js'); + for (const calendar of [ + {id: 456, name: 'Sample', alias_code: null}, + {id: 456, name: null, alias_code: 'sample'}, + ]) { + let requests = 0; + const client = new TimeTreeAPIClient({ + isAuthenticated: () => true, + getCsrfToken: () => 'synthetic-token', + getHttpClient: () => ({post: async () => {requests++; return {calendar};}}), + }); + const result = await createCreateCalendarTool(client).handler({name: 'Sample', purpose: 'work'}); + + assert.equal(requests, 1); + assert.equal(result.isError, true); + assert.deepEqual(parseToolText(result), { + error: 'Calendar creation failed. Check existing calendars before retrying.', + }); + } +}); + +test('create_calendar reports persistent TimeTree auth rejections as authentication failures', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const {TimeTreeAPIClient} = await import('../.test-dist/client/api.js'); + for (const [statusCode, code] of [[401, undefined], [400, -493], [422, -1]]) { + let requests = 0; + let signIns = 0; + const client = new TimeTreeAPIClient({ + isAuthenticated: () => true, + authenticate: async () => {signIns++;}, + getCsrfToken: () => 'synthetic-token', + getHttpClient: () => ({post: async () => { + requests++; + throw Object.assign(new Error('upstream detail'), { + statusCode, response: JSON.stringify({error: {code, message: 'upstream detail'}}), + }); + }}), + }); + const result = await createCreateCalendarTool(client).handler({name: 'Sample', purpose: 'work'}); + + assert.equal(requests, 2); + assert.equal(signIns, 1); + assert.equal(result.isError, true); + assert.deepEqual(parseToolText(result), {error: 'Authentication failed'}); + assert.equal(JSON.stringify(result).includes('upstream detail'), false); + } +});