From f10dc33c07c28191fc05ce95a0176a3421952a7b Mon Sep 17 00:00:00 2001 From: EunHyunsu Date: Thu, 1 Oct 2026 21:33:09 +0900 Subject: [PATCH] fix: align tools with live TimeTree behavior and add holiday lookup Verified against a live account and the web app: - Event and memo URLs are stored in attachment.url; the top-level url was silently ignored. Writes now map url into the attachment. - TimeTree rejects checklist: [] with HTTP 400; null clears it. - Partial attachment updates drop fields inconsistently, so updates now fetch the event (single-event GET) and send merged fields. - calendar_id accepts strings or numbers in every tool, matching the string IDs list_calendars returns. - get_events sorts by start time and gains start_before, query, label_id, and include_memos filters. - New get_holidays tool backed by the v2 memorial days endpoint. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++ COMMANDS.md | 30 ++++++- README.ja.md | 4 +- README.ko.md | 4 +- README.md | 4 +- src/client/api.ts | 110 ++++++++++++++++++++++++-- src/config/config.ts | 3 + src/tools/calendar-metadata-tools.ts | 13 +-- src/tools/comment-tools.ts | 17 ++-- src/tools/event-crud-tools.ts | 20 +++-- src/tools/event-tools.ts | 80 ++++++++++++++----- src/tools/holiday-tools.ts | 113 +++++++++++++++++++++++++++ src/tools/index.ts | 2 + src/tools/memo-tools.ts | 21 ++--- src/tools/shared-schemas.ts | 18 +++++ src/types/timetree.ts | 20 ++++- tests/api-client.test.mjs | 90 ++++++++++++++++++++- tests/tools.test.mjs | 77 ++++++++++++++++++ 18 files changed, 571 insertions(+), 70 deletions(-) create mode 100644 src/tools/holiday-tools.ts create mode 100644 src/tools/shared-schemas.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b5539..129d324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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 `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`. + +### Changed +- `get_events` now returns events sorted by start time, so `limit` keeps the earliest matches. +- Every tool now accepts `calendar_id` as a string or a number, matching the string IDs returned by `list_calendars`. + +### Fixed +- Event and memo URLs are now saved. TimeTree stores them in the attachment, so the top-level `url` was previously ignored. +- Clearing a checklist with `checklist: []` no longer fails with HTTP 400. +- Updating only the URL or only the checklist no longer drops the other attachment field. + ## [0.3.0] - 2026-05-25 ### Added diff --git a/COMMANDS.md b/COMMANDS.md index 876da5f..4338cfe 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -24,6 +24,7 @@ Quick reference for using TimeTree MCP Server with AI assistants. | **update_calendar_labels** | Merge-update calendar label names/colors | | **get_calendar_members** | Get calendar members | | **get_calendar_virtual_members** | Get virtual members | +| **get_holidays** | Get public holidays and memorial days for a date range | ## Tool Details @@ -43,18 +44,24 @@ Returns all active calendars with IDs, names, and participant info. ### get_events -Fetches all events from a calendar with optional client-side filtering. +Fetches all events from a calendar, sorted by start time, with optional client-side filtering. +Results include memos (`category=2`) unless `include_memos` is `false`. Recurring events are returned once with their RRULE; occurrences are not expanded. | Parameter | Required | Description | |-----------|----------|-------------| -| `calendar_id` | Yes | Calendar ID (from `list_calendars`) | +| `calendar_id` | Yes | Calendar ID (from `list_calendars`; string or number) | | `start_after` | No | Unix timestamp (ms) — only return events starting after this time | +| `start_before` | No | Unix timestamp (ms) — only return events starting before this time | +| `query` | No | Case-insensitive keyword matched against title, note, and location | +| `label_id` | No | Only return events with this label (1-10) | +| `include_memos` | No | Include memos (default: true) | | `limit` | No | Maximum number of events to return | **Example prompts:** - "Show events from my Personal calendar" - "What's on my schedule after June 1st?" - "Show me the next 5 events" +- "Find events about the dentist this month" --- @@ -121,7 +128,7 @@ Updates an existing event. Only provide fields you want to change. | `label_id` | No | New color | | `note` | No | New description | | `location` | No | New location | -| `url` | No | New URL | +| `url` | No | New URL; empty string removes it | | `attendees` | No | Replace calendar user attendee IDs | | `alerts` | No | Replace notification offsets in minutes; use `[]` to clear | | `recurrences` | No | Replace RRULE strings | @@ -194,6 +201,23 @@ Manage comments using TimeTree event activity endpoints. --- +### get_holidays + +Returns public holidays and memorial days that TimeTree shows on calendars. Read-only. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `country_iso` | Yes | ISO 3166-1 alpha-2 codes, e.g. `["KR"]` or `["KR", "JP"]` (max 5) | +| `start_date` | Yes | First date to include (`YYYY-MM-DD`, UTC) | +| `end_date` | Yes | Last date to include (`YYYY-MM-DD`, UTC); range up to 2 years | +| `days_off_only` | No | Exclude observances that are working days (default: false) | + +**Example prompts:** +- "Which Korean public holidays are in October?" +- "Schedule the team offsite on a weekday that isn't a holiday in Korea or Japan" + +--- + ## Label Colors Events can be color-coded with `label_id` 1-10: diff --git a/README.ja.md b/README.ja.md index 7a13abe..8402415 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,6 +20,7 @@ Claude、Codex、Cursor、Windsurf などの MCP クライアントから、Time - 🗒️ **メモ管理** - TimeTree メモの一覧取得、作成、更新、削除 - 💬 **コメント管理** - イベントコメントの追加、一覧取得、更新、削除 - 🏷️ **カレンダーメタデータ** - ラベルの取得/更新、メンバー/仮想メンバーの確認 +- 🎌 **祝日** - 国と期間を指定して祝日を取得 - 🔐 **安全な認証** - メールアドレス/パスワード認証(MCP 設定にのみ保存) - ⚡ **レート制限** - Token Bucket アルゴリズムで API 負荷を抑制 - 🔄 **自動ページネーション** - 複数ページにまたがるイベントを自動取得 @@ -122,7 +123,7 @@ npm run build ### MCP ツール - **list_calendars** - 参加ユーザー情報付きですべてのカレンダーを一覧表示 -- **get_events** - 自動ページネーションでカレンダーのイベントを取得 +- **get_events** - 自動ページネーションでカレンダーのイベントを取得(開始時刻順、キーワード/ラベル/期間フィルター) - **get_updated_events** - 指定時刻以降に更新されたイベントを取得(効率的な差分同期) - **create_event** - カレンダーに新しいイベントを作成(通知、繰り返し、参加者、チェックリストに対応) - **update_event** - 既存イベントを更新 @@ -131,6 +132,7 @@ npm run build - **add_event_comment / list_event_comments / update_event_comment / delete_event_comment** - イベントコメントを管理 - **get_calendar_labels / update_calendar_labels** - カレンダーラベルを取得またはマージ更新 - **get_calendar_members / get_calendar_virtual_members** - カレンダーメンバーのメタデータを取得 +- **get_holidays** - 期間内の祝日と記念日を取得 📖 パラメータと詳しい使い方は [COMMANDS.md](COMMANDS.md) を参照してください。 diff --git a/README.ko.md b/README.ko.md index 0c7bce1..e560412 100644 --- a/README.ko.md +++ b/README.ko.md @@ -20,6 +20,7 @@ Claude, Codex, Cursor, Windsurf 같은 MCP 클라이언트에서 TimeTree 캘린 - 🗒️ **메모 관리** - TimeTree 메모 조회, 생성, 수정, 삭제 - 💬 **댓글 관리** - 이벤트 댓글 추가, 조회, 수정, 삭제 - 🏷️ **캘린더 메타데이터** - 라벨 조회/수정 및 멤버/가상 멤버 조회 +- 🎌 **공휴일 조회** - 국가와 기간으로 공휴일 조회 - 🔐 **안전한 인증** - 이메일/비밀번호 인증 (MCP 설정에만 저장) - ⚡ **속도 제한** - Token Bucket 알고리즘으로 API 과부하 방지 - 🔄 **자동 페이지네이션** - 여러 페이지에 걸친 모든 이벤트 자동 조회 @@ -122,7 +123,7 @@ npm run build ### MCP 도구 - **list_calendars** - 참여 중인 사용자와 함께 모든 캘린더 조회 -- **get_events** - 자동 페이지네이션으로 캘린더 이벤트 조회 +- **get_events** - 자동 페이지네이션으로 캘린더 이벤트 조회 (시작 시간순 정렬, 키워드/라벨/기간 필터) - **get_updated_events** - 특정 시간 이후 업데이트된 이벤트 조회 (효율적인 증분 동기화) - **create_event** - 캘린더에 새 이벤트 생성 (알림, 반복, 참석자, 체크리스트 지원) - **update_event** - 기존 이벤트 수정 @@ -131,6 +132,7 @@ npm run build - **add_event_comment / list_event_comments / update_event_comment / delete_event_comment** - 이벤트 댓글 관리 - **get_calendar_labels / update_calendar_labels** - 캘린더 라벨 조회 또는 병합 업데이트 - **get_calendar_members / get_calendar_virtual_members** - 캘린더 멤버 메타데이터 조회 +- **get_holidays** - 기간 내 공휴일과 기념일 조회 📖 파라미터와 사용 세부사항은 [COMMANDS.md](COMMANDS.md) 참조 diff --git a/README.md b/README.md index 3ee7577..80c40e2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Use Claude, Codex, Cursor, Windsurf, and other MCP clients to read and manage yo - 🗒️ **Manage Memos** - List, create, update, and delete TimeTree memos - 💬 **Manage Comments** - Add, list, update, and delete event comments - 🏷️ **Calendar Metadata** - Read/update labels and inspect members/virtual members +- 🎌 **Holidays** - Look up public holidays by country and date range - 🔐 **Secure Authentication** - Email/password authentication (stored only in MCP config) - ⚡ **Rate Limiting** - Token bucket algorithm to prevent API overload - 🔄 **Auto Pagination** - Automatically fetches all events across multiple pages @@ -122,7 +123,7 @@ Then restart your MCP client. ### MCP Tools - **list_calendars** - List all calendars with participating users -- **get_events** - Get events from a calendar with auto-pagination +- **get_events** - Get events from a calendar with auto-pagination, sorted by start time, with keyword/label/date filters - **get_updated_events** - Get events updated after a specific timestamp (efficient incremental sync) - **create_event** - Create a new event in a calendar (supports alerts, recurrences, attendees, checklist) - **update_event** - Update an existing event @@ -131,6 +132,7 @@ Then restart your MCP client. - **add_event_comment / list_event_comments / update_event_comment / delete_event_comment** - Manage event comments - **get_calendar_labels / update_calendar_labels** - Read or merge-update calendar labels - **get_calendar_members / get_calendar_virtual_members** - Read calendar member metadata +- **get_holidays** - Get public holidays and memorial days for a date range 📖 See [COMMANDS.md](COMMANDS.md) for parameters and usage details. diff --git a/src/client/api.ts b/src/client/api.ts index 305fb25..00f733c 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -26,6 +26,8 @@ import type { EventActivity, EventActivityResponse, EventsSyncResponse, + MemorialDay, + MemorialDaysResponse, UpdateEventInput, UpdateEventResponse, UpdateMemoInput, @@ -39,6 +41,7 @@ import { EventActivitiesResponseSchema, EventActivityResponseSchema, EventsSyncResponseSchema, + MemorialDaysResponseSchema, UpdateEventResponseSchema, } from '../types/timetree.js'; @@ -77,6 +80,43 @@ function createClientUuid(): string { return randomUUID().replace(/-/g, ''); } +type EventAttachmentInput = NonNullable; + +/** + * Shape an attachment for TimeTree writes. + * The event URL is stored as attachment.url (a top-level url is ignored), and an + * empty checklist must be sent as null because TimeTree rejects []. + */ +export function toAttachmentPayload( + attachment: EventAttachmentInput | null | undefined, + url: string | undefined +): EventAttachmentInput | null | undefined { + if (url === undefined && attachment == null) { + return attachment; + } + + const payload: EventAttachmentInput = { ...(attachment ?? {}) }; + if (url !== undefined) { + payload.url = url || null; + } + if (Array.isArray(payload.checklist) && payload.checklist.length === 0) { + payload.checklist = null; + } + return payload; +} + +/** Keep the attachment fields TimeTree drops when an update omits them. */ +function existingAttachmentFields(event: Event): EventAttachmentInput { + const attachment = event.attachment; + return { + ...(attachment?.url && { url: attachment.url }), + ...(attachment?.checklist?.length && { checklist: attachment.checklist }), + ...(attachment?.virtual_user_attendees?.length && { + virtual_user_attendees: attachment.virtual_user_attendees, + }), + }; +} + export class TimeTreeAPIClient { private authManager: TimeTreeAuthManager; private rateLimiter: RateLimiter; @@ -275,6 +315,30 @@ export class TimeTreeAPIClient { } } + /** Get public holidays and memorial days for countries within [from, to). */ + async getMemorialDays(countryIsos: string[], from: Date, to: Date): Promise { + await this.ensureAuthenticated(); + + const params = new URLSearchParams(); + for (const country of countryIsos) params.append('country_iso[]', country); + params.set('from', from.toISOString()); + params.set('to', to.toISOString()); + const url = `${TIMETREE_CONFIG.V2_BASE_URL}${TIMETREE_CONFIG.ENDPOINTS.MEMORIAL_DAYS}?${params}`; + + try { + const response = await this.rateLimiter.executeWithRetry(async () => { + return await this.authManager.getHttpClient().get(url); + }); + return MemorialDaysResponseSchema.parse(response).memorialdays.filter((day) => !day.deactivated_at); + } catch (error) { + logger.error('Failed to fetch memorial days', { countryIsos, error }); + throw new TimeTreeAPIError( + `Failed to fetch holidays: ${getErrorMessage(error)}`, + getStatusCode(error) + ); + } + } + async updateCalendarLabels( calendarId: string, labelUpdates: CalendarLabelUpdateInput[] @@ -405,12 +469,14 @@ export class TimeTreeAPIClient { logger.info('Creating event', { calendarId, title: eventData.title }); const url = `${TIMETREE_CONFIG.BASE_URL}${TIMETREE_CONFIG.ENDPOINTS.CREATE_EVENT(calendarId)}`; + const { url: eventUrl, ...rest } = eventData; + const body = { ...rest, attachment: toAttachmentPayload(rest.attachment, eventUrl) }; try { const response = await this.rateLimiter.executeWithRetry(async () => { return await this.authManager .getHttpClient() - .post(url, eventData, undefined, true); + .post(url, body, undefined, true); }); const validated = CreateEventResponseSchema.parse(response); @@ -455,10 +521,21 @@ export class TimeTreeAPIClient { )}`; try { + const { url: eventUrl, ...body } = updateData; + if (eventUrl !== undefined || body.attachment != null) { + // TimeTree replaces attachment fields inconsistently on partial updates, so send + // the current url/checklist/virtual attendees together with the changes. + const current = await this.getEvent(calendarId, eventUuid); + body.attachment = toAttachmentPayload( + { ...existingAttachmentFields(current), ...(body.attachment ?? {}) }, + eventUrl + ); + } + const response = await this.rateLimiter.executeWithRetry(async () => { return await this.authManager .getHttpClient() - .put(url, updateData, undefined, true); + .put(url, body, undefined, true); }); const validated = UpdateEventResponseSchema.parse(response); @@ -487,10 +564,33 @@ export class TimeTreeAPIClient { } } - /** Fetch a single event by UUID using the sync endpoint with targeted search. */ + /** Fetch a single event by UUID. */ + async getEvent(calendarId: string, eventUuid: string): Promise { + await this.ensureAuthenticated(); + + const url = `${TIMETREE_CONFIG.BASE_URL}${TIMETREE_CONFIG.ENDPOINTS.EVENT(calendarId, eventUuid)}`; + + try { + const response = await this.rateLimiter.executeWithRetry(async () => { + return await this.authManager.getHttpClient().get(url); + }); + return CreateEventResponseSchema.parse(response).event; + } catch (error) { + if (getStatusCode(error) === 404) { + throw new TimeTreeAPIError(`Event not found: ${eventUuid}`, 404); + } + throw error; + } + } + + /** Fetch a single event by UUID, or null when it does not exist. */ private async getEventByUuid(calendarId: string, eventUuid: string): Promise { - const events = await this.syncEvents(calendarId, 0); - return events.find((event) => event.uuid === eventUuid) || null; + try { + return await this.getEvent(calendarId, eventUuid); + } catch (error) { + if (getStatusCode(error) === 404) return null; + throw error; + } } /** diff --git a/src/config/config.ts b/src/config/config.ts index 38cee8a..b90416c 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -14,10 +14,13 @@ export const TIMETREE_CONFIG = { CALENDARS: '/calendars', CALENDAR_LABELS: (calendarId: string) => `/calendar/${calendarId}/labels`, CALENDAR_MEMBERS_V2: (calendarId: string) => `/calendars/${calendarId}/users`, + MEMORIAL_DAYS: '/memorialdays', CALENDAR_VIRTUAL_USERS: (calendarId: string) => `/calendars/${calendarId}/virtual_users`, EVENTS_SYNC: (calendarId: string) => `/calendar/${calendarId}/events/sync`, EVENTS: (calendarId: string) => `/calendar/${calendarId}/events`, CREATE_EVENT: (calendarId: string) => `/calendar/${calendarId}/event`, + EVENT: (calendarId: string, eventUuid: string) => + `/calendar/${calendarId}/event/${eventUuid}`, UPDATE_EVENT: (calendarId: string, eventUuid: string) => `/calendar/${calendarId}/event/${eventUuid}`, DELETE_EVENT: (calendarId: string, eventUuid: string) => diff --git a/src/tools/calendar-metadata-tools.ts b/src/tools/calendar-metadata-tools.ts index af2d058..dbbc6f3 100644 --- a/src/tools/calendar-metadata-tools.ts +++ b/src/tools/calendar-metadata-tools.ts @@ -7,14 +7,15 @@ import { z } from 'zod'; import type { TimeTreeAPIClient } from '../client/api.js'; import { InvalidCalendarError, TimeTreeAPIError } from '../client/api.js'; import { logger } from '../utils/logger.js'; +import { CALENDAR_ID_JSON_SCHEMA, CalendarIdSchema } from './shared-schemas.js'; export const GetCalendarMetadataInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), include_deactivated: z.boolean().default(false).optional().describe('Include deactivated users when supported'), }); export const UpdateCalendarLabelsInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), labels: z.array(z.object({ id: z.number().min(1).max(10).describe('Label ID 1-10'), name: z.string().optional().describe('New label name'), @@ -65,7 +66,7 @@ export function createGetCalendarLabelsTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, }, required: ['calendar_id'], }, @@ -114,7 +115,7 @@ export function createUpdateCalendarLabelsTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, labels: { type: 'array', description: 'Labels to update/merge. Omitted labels are preserved.', @@ -179,7 +180,7 @@ export function createGetCalendarMembersTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, include_deactivated: { type: 'boolean', description: 'Include deactivated members (default: false)' }, }, required: ['calendar_id'], @@ -231,7 +232,7 @@ export function createGetCalendarVirtualMembersTool(apiClient: TimeTreeAPIClient inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, include_deactivated: { type: 'boolean', description: 'Include deactivated virtual members (default: false)' }, }, required: ['calendar_id'], diff --git a/src/tools/comment-tools.ts b/src/tools/comment-tools.ts index f181300..b2f7437 100644 --- a/src/tools/comment-tools.ts +++ b/src/tools/comment-tools.ts @@ -7,29 +7,30 @@ import { z } from 'zod'; import type { TimeTreeAPIClient } from '../client/api.js'; import { TimeTreeAPIError } from '../client/api.js'; import { logger } from '../utils/logger.js'; +import { CALENDAR_ID_JSON_SCHEMA, CalendarIdSchema } from './shared-schemas.js'; import type { EventActivity } from '../types/timetree.js'; export const AddEventCommentInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), event_uuid: z.string().describe('The event UUID to comment on'), content: z.string().min(1).describe('Comment content'), silent: z.boolean().default(true).describe('Whether to avoid push notifications when supported'), }); export const ListEventCommentsInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), event_uuid: z.string().describe('The event UUID'), }); export const UpdateEventCommentInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), event_uuid: z.string().describe('The event UUID'), comment_id: z.string().describe('The comment/activity ID'), content: z.string().min(1).describe('New comment content'), }); export const DeleteEventCommentInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), event_uuid: z.string().describe('The event UUID'), comment_id: z.string().describe('The comment/activity ID'), }); @@ -91,7 +92,7 @@ export function createAddEventCommentTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, event_uuid: { type: 'string', description: 'The event UUID to comment on' }, content: { type: 'string', description: 'Comment content' }, silent: { type: 'boolean', description: 'Whether to avoid push notifications when supported (default: true)' }, @@ -141,7 +142,7 @@ export function createListEventCommentsTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, event_uuid: { type: 'string', description: 'The event UUID' }, }, required: ['calendar_id', 'event_uuid'], @@ -185,7 +186,7 @@ export function createUpdateEventCommentTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, event_uuid: { type: 'string', description: 'The event UUID' }, comment_id: { type: 'string', description: 'The comment/activity ID' }, content: { type: 'string', description: 'New comment content' }, @@ -239,7 +240,7 @@ export function createDeleteEventCommentTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, event_uuid: { type: 'string', description: 'The event UUID' }, comment_id: { type: 'string', description: 'The comment/activity ID' }, }, diff --git a/src/tools/event-crud-tools.ts b/src/tools/event-crud-tools.ts index a8ee1f8..f7d4757 100644 --- a/src/tools/event-crud-tools.ts +++ b/src/tools/event-crud-tools.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import type { TimeTreeAPIClient } from '../client/api.js'; import { InvalidCalendarError, TimeTreeAPIError } from '../client/api.js'; import { logger } from '../utils/logger.js'; +import { CALENDAR_ID_JSON_SCHEMA, CalendarIdSchema } from './shared-schemas.js'; import { getLabelColorName } from '../types/label-colors.js'; import type { ChecklistItem, Event } from '../types/timetree.js'; @@ -115,7 +116,7 @@ function commonWriteError(error: unknown, action: string) { // ============================================================================ export const CreateEventInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID to create the event in'), + calendar_id: CalendarIdSchema.describe('The calendar ID to create the event in'), title: z.string().min(1).describe('Event title'), all_day: z.boolean().default(false).describe('Whether this is an all-day event'), start_at: z.number().describe('Event start time as Unix timestamp in milliseconds'), @@ -147,10 +148,7 @@ export function createCreateEventTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { - type: 'number', - description: 'The calendar ID to create the event in (use list_calendars to get valid IDs)', - }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, title: { type: 'string', description: 'Event title (required)' }, all_day: { type: 'boolean', @@ -272,7 +270,7 @@ export function createCreateEventTool(apiClient: TimeTreeAPIClient) { // ============================================================================ export const UpdateEventInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), event_uuid: z.string().describe('The UUID of the event to update'), title: z.string().min(1).optional().describe('New event title'), all_day: z.boolean().optional().describe('Whether this is an all-day event'), @@ -284,7 +282,7 @@ export const UpdateEventInputSchema = z.object({ category: z.number().optional().describe('New event category'), note: z.string().optional().describe('New event notes'), location: z.string().optional().describe('New event location'), - url: z.string().optional().describe('New related URL'), + url: z.string().optional().describe('New related URL; use an empty string to remove it'), attendees: z.array(z.number()).optional().describe('Replace calendar user attendee IDs'), recurrences: z.array(z.string()).optional().describe('Replace recurrence RRULE strings'), alerts: z.array(z.number()).optional().describe('Replace notification offsets in minutes'), @@ -303,7 +301,7 @@ export function createUpdateEventTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, event_uuid: { type: 'string', description: 'The UUID of the event to update (from get_events)' }, title: { type: 'string', description: 'New event title' }, all_day: { type: 'boolean', description: 'Whether this is an all-day event' }, @@ -315,7 +313,7 @@ export function createUpdateEventTool(apiClient: TimeTreeAPIClient) { category: { type: 'number', description: 'New event category' }, note: { type: 'string', description: 'New event notes' }, location: { type: 'string', description: 'New event location' }, - url: { type: 'string', description: 'New related URL' }, + url: { type: 'string', description: 'New related URL; use an empty string to remove it' }, attendees: { type: 'array', description: 'Replace calendar user attendee IDs', @@ -418,7 +416,7 @@ export function createUpdateEventTool(apiClient: TimeTreeAPIClient) { // ============================================================================ export const DeleteEventInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), event_uuid: z.string().describe('The UUID of the event to delete'), }); @@ -431,7 +429,7 @@ export function createDeleteEventTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, event_uuid: { type: 'string', description: 'The UUID of the event to delete (from get_events)' }, }, required: ['calendar_id', 'event_uuid'], diff --git a/src/tools/event-tools.ts b/src/tools/event-tools.ts index 0a5c45d..53147b9 100644 --- a/src/tools/event-tools.ts +++ b/src/tools/event-tools.ts @@ -8,15 +8,30 @@ import type { TimeTreeAPIClient } from '../client/api.js'; import { InvalidCalendarError } from '../client/api.js'; import { logger } from '../utils/logger.js'; import { getLabelColorName } from '../types/label-colors.js'; +import { CALENDAR_ID_JSON_SCHEMA, CalendarIdSchema } from './shared-schemas.js'; export const GetEventsInputSchema = z.object({ - calendar_id: z.string().describe('The calendar ID to fetch events from'), + calendar_id: CalendarIdSchema.describe('The calendar ID to fetch events from'), start_after: z .number() .optional() .describe( 'Optional Unix timestamp in milliseconds. Only return events starting after this time.' ), + start_before: z + .number() + .optional() + .describe('Optional Unix timestamp in milliseconds. Only return events starting before this time.'), + query: z + .string() + .min(1) + .optional() + .describe('Optional case-insensitive keyword matched against title, note, and location.'), + label_id: z.number().int().min(1).max(10).optional().describe('Optional label ID (1-10) filter.'), + include_memos: z + .boolean() + .default(true) + .describe('Whether to include memos (category=2). Defaults to true.'), limit: z .number() .optional() @@ -24,7 +39,7 @@ export const GetEventsInputSchema = z.object({ }); export const GetUpdatedEventsInputSchema = z.object({ - calendar_id: z.string().describe('The calendar ID to fetch updated events from'), + calendar_id: CalendarIdSchema.describe('The calendar ID to fetch updated events from'), updated_after: z .number() .describe('Unix timestamp in milliseconds. Only return events updated after this time.'), @@ -38,23 +53,38 @@ export function createGetEventsTool(apiClient: TimeTreeAPIClient) { return { name: 'get_events', description: - 'Get all events from a specific TimeTree calendar. Automatically handles pagination to fetch all events. ' + + 'Get events from a specific TimeTree calendar, sorted by start time. Automatically handles pagination. ' + + 'Supports filtering by start time range, keyword, label, and whether to include memos. ' + + 'Recurring events are returned once with their RRULE; occurrences are not expanded. ' + 'Returns event details including title, start/end times, location, notes, label color, and more. ' + 'Label colors (label_id 1-10): 1=Emerald green, 2=Modern cyan, 3=Deep sky blue, 4=Pastel brown, ' + '5=Midnight black, 6=Apple red, 7=French rose, 8=Coral pink, 9=Bright orange, 10=Soft violet.', inputSchema: { type: 'object', properties: { - calendar_id: { - type: 'string', - description: 'The calendar ID to fetch events from', - }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, start_after: { type: 'number', description: 'Optional Unix timestamp in milliseconds. Only return events starting after this time. ' + 'If user provides a date like "2026-02-01", convert it to Unix timestamp (e.g., 1769904000000).', }, + start_before: { + type: 'number', + description: 'Optional Unix timestamp in milliseconds. Only return events starting before this time.', + }, + query: { + type: 'string', + description: 'Optional case-insensitive keyword matched against title, note, and location.', + }, + label_id: { + type: 'number', + description: 'Optional label ID (1-10) filter.', + }, + include_memos: { + type: 'boolean', + description: 'Whether to include memos (category=2). Defaults to true.', + }, limit: { type: 'number', description: 'Optional limit on number of events to return.', @@ -65,17 +95,27 @@ export function createGetEventsTool(apiClient: TimeTreeAPIClient) { handler: async (args: unknown) => { try { const input = GetEventsInputSchema.parse(args); - const { calendar_id, start_after, limit } = input; + const { start_after, start_before, query, label_id, include_memos, limit } = input; + const calendar_id = String(input.calendar_id); - logger.info('Tool: get_events called', { calendar_id, start_after, limit }); + logger.info('Tool: get_events called', { calendar_id, start_after, start_before, limit }); const events = await apiClient.getEventsByCalendar(calendar_id, 0); - // Filter by start_after if provided - let filteredEvents = events; - if (start_after) { - filteredEvents = events.filter((event) => event.start_at > start_after); - } + const keyword = query?.toLowerCase(); + let filteredEvents = events + .filter((event) => start_after === undefined || event.start_at > start_after) + .filter((event) => start_before === undefined || event.start_at < start_before) + .filter((event) => label_id === undefined || event.label_id === label_id) + .filter((event) => include_memos || event.category !== 2) + .filter( + (event) => + keyword === undefined || + [event.title, event.note, event.location].some((field) => + field?.toLowerCase().includes(keyword) + ) + ) + .sort((a, b) => a.start_at - b.start_at); // Limit results if provided if (limit) { @@ -162,7 +202,7 @@ export function createGetEventsTool(apiClient: TimeTreeAPIClient) { text: JSON.stringify( { error: 'Invalid input', - message: 'Please provide a valid calendar_id (string)', + message: 'Please provide a valid calendar_id', details: (error as any).errors, }, null, @@ -207,10 +247,7 @@ export function createGetUpdatedEventsTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { - type: 'string', - description: 'The calendar ID to fetch updated events from', - }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, updated_after: { type: 'number', description: @@ -227,7 +264,8 @@ export function createGetUpdatedEventsTool(apiClient: TimeTreeAPIClient) { handler: async (args: unknown) => { try { const input = GetUpdatedEventsInputSchema.parse(args); - const { calendar_id, updated_after, limit } = input; + const { updated_after, limit } = input; + const calendar_id = String(input.calendar_id); logger.info('Tool: get_updated_events called', { calendar_id, updated_after, limit }); @@ -320,7 +358,7 @@ export function createGetUpdatedEventsTool(apiClient: TimeTreeAPIClient) { text: JSON.stringify( { error: 'Invalid input', - message: 'Please provide valid calendar_id (string) and updated_after (number) parameters', + message: 'Please provide valid calendar_id and updated_after (number) parameters', details: (error as any).errors, }, null, diff --git a/src/tools/holiday-tools.ts b/src/tools/holiday-tools.ts new file mode 100644 index 0000000..715102a --- /dev/null +++ b/src/tools/holiday-tools.ts @@ -0,0 +1,113 @@ +/** + * Holiday Tools for MCP + * Reads public holidays and memorial days that TimeTree shows on calendars. + */ + +import { z } from 'zod'; +import type { TimeTreeAPIClient } from '../client/api.js'; +import { logger } from '../utils/logger.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_RANGE_DAYS = 731; + +const DateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD'); + +export const GetHolidaysInputSchema = z.object({ + country_iso: z + .array(z.string().regex(/^[A-Za-z]{2}$/, 'Use ISO 3166-1 alpha-2 codes such as KR or JP')) + .min(1) + .max(5) + .describe('Country codes, e.g. ["KR"] or ["KR", "JP"]'), + start_date: DateSchema.describe('First date to include (YYYY-MM-DD, UTC)'), + end_date: DateSchema.describe('Last date to include (YYYY-MM-DD, UTC)'), + days_off_only: z + .boolean() + .default(false) + .describe('Only return days off (exclude observances that are working days)'), +}); + +function toDateString(timestamp: number): string { + return new Date(timestamp).toISOString().slice(0, 10); +} + +function errorContent(error: string, message: string, details?: unknown) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ error, message, ...(details !== undefined && { details }) }, null, 2), + }, + ], + isError: true, + }; +} + +export function createGetHolidaysTool(apiClient: TimeTreeAPIClient) { + return { + name: 'get_holidays', + description: + 'Get public holidays and memorial days for one or more countries in a date range (up to 2 years). ' + + 'Useful for scheduling around days off. Read-only.', + inputSchema: { + type: 'object', + properties: { + country_iso: { + type: 'array', + items: { type: 'string' }, + description: 'ISO 3166-1 alpha-2 country codes, e.g. ["KR"] or ["KR", "JP"] (max 5)', + }, + start_date: { type: 'string', description: 'First date to include (YYYY-MM-DD, UTC)' }, + end_date: { type: 'string', description: 'Last date to include (YYYY-MM-DD, UTC)' }, + days_off_only: { + type: 'boolean', + description: 'Only return days off, excluding observances that are working days (default: false)', + }, + }, + required: ['country_iso', 'start_date', 'end_date'], + }, + handler: async (args: unknown) => { + try { + const input = GetHolidaysInputSchema.parse(args); + const from = new Date(`${input.start_date}T00:00:00Z`); + const to = new Date(new Date(`${input.end_date}T00:00:00Z`).getTime() + DAY_MS); + + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) { + return errorContent('Invalid input', 'start_date must be on or before end_date'); + } + if ((to.getTime() - from.getTime()) / DAY_MS > MAX_RANGE_DAYS) { + return errorContent('Invalid input', 'Date range must be 2 years or less'); + } + + const countries = [...new Set(input.country_iso.map((code) => code.toUpperCase()))]; + logger.info('Tool: get_holidays called', { countries, start_date: input.start_date, end_date: input.end_date }); + + const days = await apiClient.getMemorialDays(countries, from, to); + const holidays = days + .filter((day) => !input.days_off_only || day.workday !== true) + .sort((a, b) => a.start_at - b.start_at) + .map((day) => ({ + date: toDateString(day.start_at), + end_date: toDateString(day.end_at), + title: day.title, + country_iso: day.country_iso, + day_off: day.workday !== true, + })); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ holidays, total: holidays.length }, null, 2), + }, + ], + }; + } catch (error) { + logger.error('Tool: get_holidays failed', { error }); + if ((error as { name?: string }).name === 'ZodError') { + return errorContent('Invalid input', 'Please provide valid holiday query parameters', (error as { errors?: unknown }).errors); + } + return errorContent('Failed to get holidays', (error as Error).message); + } + }, + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e9329ec..abd007b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -23,6 +23,7 @@ import { createDeleteEventTool, } from './event-crud-tools.js'; import { createGetEventsTool, createGetUpdatedEventsTool } from './event-tools.js'; +import { createGetHolidaysTool } from './holiday-tools.js'; import { createCreateMemoTool, createDeleteMemoTool, @@ -50,5 +51,6 @@ export function registerTools(apiClient: TimeTreeAPIClient) { createUpdateCalendarLabelsTool(apiClient), createGetCalendarMembersTool(apiClient), createGetCalendarVirtualMembersTool(apiClient), + createGetHolidaysTool(apiClient), ]; } diff --git a/src/tools/memo-tools.ts b/src/tools/memo-tools.ts index 505cf2d..7d931e6 100644 --- a/src/tools/memo-tools.ts +++ b/src/tools/memo-tools.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import type { TimeTreeAPIClient } from '../client/api.js'; import { InvalidCalendarError, TimeTreeAPIError } from '../client/api.js'; import { logger } from '../utils/logger.js'; +import { CALENDAR_ID_JSON_SCHEMA, CalendarIdSchema } from './shared-schemas.js'; import { getLabelColorName } from '../types/label-colors.js'; import type { Event } from '../types/timetree.js'; @@ -18,13 +19,13 @@ const ChecklistSchema = z.array(z.object({ const VirtualUserAttendeesSchema = z.array(z.union([z.string(), z.number()])); export const ListMemosInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID to list memos from'), + calendar_id: CalendarIdSchema.describe('The calendar ID to list memos from'), updated_after: z.number().optional().describe('Only return memos updated after this Unix timestamp in milliseconds'), limit: z.number().positive().optional().describe('Optional limit on number of memos returned'), }); export const CreateMemoInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID to create the memo in'), + calendar_id: CalendarIdSchema.describe('The calendar ID to create the memo in'), title: z.string().min(1).describe('Memo title'), note: z.string().optional().describe('Memo body text'), label_id: z.number().min(1).max(10).optional().describe('Color label ID (1-10)'), @@ -36,19 +37,19 @@ export const CreateMemoInputSchema = z.object({ }); export const UpdateMemoInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), memo_uuid: z.string().describe('The memo UUID (same as event UUID)'), title: z.string().min(1).optional().describe('New memo title'), note: z.string().optional().describe('New memo body text'), label_id: z.number().min(1).max(10).optional().describe('New color label ID (1-10)'), location: z.string().optional().describe('New memo location'), - url: z.string().optional().describe('New related URL'), + url: z.string().optional().describe('New related URL; use an empty string to remove it'), checklist: ChecklistSchema.optional().describe('Replace checklist items; use [] to clear'), virtual_user_attendees: VirtualUserAttendeesSchema.optional().describe('Replace virtual member attendees; use [] to clear'), }); export const DeleteMemoInputSchema = z.object({ - calendar_id: z.number().describe('The calendar ID'), + calendar_id: CalendarIdSchema.describe('The calendar ID'), memo_uuid: z.string().describe('The memo UUID (same as event UUID)'), }); @@ -123,7 +124,7 @@ export function createListMemosTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID to list memos from' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, updated_after: { type: 'number', description: 'Only return memos updated after this Unix timestamp in milliseconds' }, limit: { type: 'number', description: 'Optional limit on number of memos returned' }, }, @@ -173,7 +174,7 @@ export function createCreateMemoTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID to create the memo in' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, title: { type: 'string', description: 'Memo title' }, note: { type: 'string', description: 'Memo body text' }, label_id: { type: 'number', description: 'Color label ID (1-10)' }, @@ -247,13 +248,13 @@ export function createUpdateMemoTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, memo_uuid: { type: 'string', description: 'The memo UUID (same as event UUID)' }, title: { type: 'string', description: 'New memo title' }, note: { type: 'string', description: 'New memo body text' }, label_id: { type: 'number', description: 'New color label ID (1-10)' }, location: { type: 'string', description: 'New memo location' }, - url: { type: 'string', description: 'New related URL' }, + url: { type: 'string', description: 'New related URL; use an empty string to remove it' }, checklist: { type: 'array', description: 'Replace checklist items; use [] to clear', @@ -320,7 +321,7 @@ export function createDeleteMemoTool(apiClient: TimeTreeAPIClient) { inputSchema: { type: 'object', properties: { - calendar_id: { type: 'number', description: 'The calendar ID' }, + calendar_id: CALENDAR_ID_JSON_SCHEMA, memo_uuid: { type: 'string', description: 'The memo UUID (same as event UUID)' }, }, required: ['calendar_id', 'memo_uuid'], diff --git a/src/tools/shared-schemas.ts b/src/tools/shared-schemas.ts new file mode 100644 index 0000000..f3a8f4f --- /dev/null +++ b/src/tools/shared-schemas.ts @@ -0,0 +1,18 @@ +/** + * Input schemas shared across MCP tools. + */ + +import { z } from 'zod'; + +/** + * Calendar IDs are numeric, but list_calendars returns them as strings. + * Accept both forms so an ID can be passed through unchanged, and normalize to a number. + */ +export const CalendarIdSchema = z + .union([z.number().int().positive(), z.string().regex(/^\d+$/, 'calendar_id must be numeric')]) + .transform((value) => Number(value)); + +export const CALENDAR_ID_JSON_SCHEMA = { + type: 'string', + description: 'The calendar ID from list_calendars (numeric string; numbers are also accepted)', +} as const; diff --git a/src/types/timetree.ts b/src/types/timetree.ts index 5650e45..6fc7751 100644 --- a/src/types/timetree.ts +++ b/src/types/timetree.ts @@ -21,7 +21,8 @@ export const EventAttachmentSchema = z.object({ export const EventAttachmentInputSchema = z.object({ url: z.string().optional().nullable(), content: z.string().optional(), - checklist: z.array(ChecklistItemSchema).optional(), + // TimeTree rejects an empty checklist array; null clears it. + checklist: z.array(ChecklistItemSchema).optional().nullable(), virtual_user_attendees: z.array(z.any()).optional(), }).passthrough(); @@ -154,6 +155,23 @@ export const CalendarLabelsResponseSchema = z.object({ export type CalendarLabelsResponse = z.infer; +export const MemorialDaySchema = z.object({ + id: z.union([z.number(), z.string()]), + country_iso: z.string(), + title: z.string(), + workday: z.boolean().optional(), + start_at: z.number(), + end_at: z.number(), + deactivated_at: z.number().nullable().optional(), +}).passthrough(); + +export const MemorialDaysResponseSchema = z.object({ + memorialdays: z.array(MemorialDaySchema), +}).passthrough(); + +export type MemorialDay = z.infer; +export type MemorialDaysResponse = z.infer; + export const CalendarUsersResponseSchema = z.object({ calendar_users: z.array(CalendarUserSchema), }).passthrough(); diff --git a/tests/api-client.test.mjs b/tests/api-client.test.mjs index de6cac0..043227f 100644 --- a/tests/api-client.test.mjs +++ b/tests/api-client.test.mjs @@ -68,7 +68,7 @@ test('deleteEvent falls back to full-event-body DELETE on non-404 no-body failur }, get: async (url) => { calls.push({ url, method: 'GET' }); - return { events: [targetEvent], chunk: false, since: 0 }; + return { event: targetEvent }; }, }); @@ -76,7 +76,7 @@ test('deleteEvent falls back to full-event-body DELETE on non-404 no-body failur assert.equal(deleteCount, 2); assert.equal(calls[0].body, undefined); - assert.equal(calls[1].url, 'https://timetreeapp.com/api/v1/calendar/cal-1/events/sync?since=0'); + assert.equal(calls[1].url, 'https://timetreeapp.com/api/v1/calendar/cal-1/event/event-1'); assert.equal(calls[2].body.uuid, 'event-1'); assert.equal(calls[2].requiresCsrf, true); }); @@ -166,3 +166,89 @@ 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('createEvent stores url in attachment and sends an empty checklist as null', async () => { + const calls = []; + const client = makeClient({ + post: async (url, body) => { + calls.push({ url, body }); + return { event: makeEvent({ uuid: 'created' }) }; + }, + }); + + await client.createEvent('cal-1', { + title: 'event', + all_day: false, + start_at: 1, + end_at: 2, + url: 'https://example.com', + attachment: { checklist: [] }, + }); + + assert.equal('url' in calls[0].body, false); + assert.deepEqual(calls[0].body.attachment, { checklist: null, url: 'https://example.com' }); +}); + +test('updateEvent merges current attachment fields before sending attachment changes', async () => { + const calls = []; + const client = makeClient({ + get: async (url) => { + calls.push({ method: 'GET', url }); + return { + event: makeEvent({ + uuid: 'evt', + attachment: { url: 'https://example.com/keep', checklist: [{ title: 'a', checked: false }] }, + }), + }; + }, + put: async (url, body) => { + calls.push({ method: 'PUT', url, body }); + return { event: makeEvent({ uuid: 'evt' }) }; + }, + }); + + await client.updateEvent('cal-1', 'evt', { attachment: { checklist: [] } }); + + assert.equal(calls[0].url, 'https://timetreeapp.com/api/v1/calendar/cal-1/event/evt'); + assert.deepEqual(calls[1].body.attachment, { url: 'https://example.com/keep', checklist: null }); +}); + +test('updateEvent without attachment changes does not fetch the current event', async () => { + const calls = []; + const client = makeClient({ + get: async () => { + throw new Error('current event should not be fetched'); + }, + put: async (url, body) => { + calls.push({ url, body }); + return { event: makeEvent({ uuid: 'evt' }) }; + }, + }); + + await client.updateEvent('cal-1', 'evt', { title: 'renamed' }); + + assert.deepEqual(calls[0].body, { title: 'renamed' }); +}); + +test('getMemorialDays queries v2 memorial days and drops deactivated entries', async () => { + let requested; + const client = makeClient({ + get: async (url) => { + requested = url; + return { + memorialdays: [ + { id: 1, country_iso: 'KR', title: 'active', workday: false, start_at: 10, end_at: 10, deactivated_at: null }, + { id: 2, country_iso: 'KR', title: 'removed', workday: false, start_at: 20, end_at: 20, deactivated_at: 5 }, + ], + }; + }, + }); + + const days = await client.getMemorialDays(['KR', 'JP'], new Date('2026-10-01T00:00:00Z'), new Date('2026-11-01T00:00:00Z')); + + const url = new URL(requested); + assert.equal(url.pathname, '/api/v2/memorialdays'); + assert.deepEqual(url.searchParams.getAll('country_iso[]'), ['KR', 'JP']); + assert.equal(url.searchParams.get('from'), '2026-10-01T00:00:00.000Z'); + assert.deepEqual(days.map((day) => day.title), ['active']); +}); diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index 550da8c..5bdddf2 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -3,6 +3,9 @@ import assert from 'node:assert/strict'; import { registerTools } from '../.test-dist/tools/index.js'; import { createCreateEventTool, createUpdateEventTool } from '../.test-dist/tools/event-crud-tools.js'; import { createCreateMemoTool } from '../.test-dist/tools/memo-tools.js'; +import { createGetEventsTool } from '../.test-dist/tools/event-tools.js'; +import { createGetHolidaysTool } from '../.test-dist/tools/holiday-tools.js'; +import { createGetCalendarLabelsTool } from '../.test-dist/tools/calendar-metadata-tools.js'; function makeEvent(overrides = {}) { return { @@ -45,6 +48,7 @@ test('registerTools exposes baseline plus Wave 1 tools', () => { 'get_calendar_members', 'get_calendar_virtual_members', 'get_events', + 'get_holidays', 'get_updated_events', 'list_calendars', 'list_event_comments', @@ -167,3 +171,76 @@ test('create_memo wraps input as category=2 memo data with UTC date', async () = assert.deepEqual(captured.data.checklist, [{ title: 'memo item', checked: true }]); assert.equal(parseToolText(result).memo.memo_date, '2026-05-25'); }); + +test('get_events accepts numeric IDs, filters, and sorts by start time', async () => { + let requestedId; + const tool = createGetEventsTool({ + getEventsByCalendar: async (calendarId) => { + requestedId = calendarId; + return [ + makeEvent({ uuid: 'late', title: 'Team sync', start_at: 300, end_at: 301, label_id: 3 }), + makeEvent({ uuid: 'memo', title: 'Team memo', start_at: 50, end_at: 50, category: 2, label_id: 3 }), + makeEvent({ uuid: 'early', title: 'Lunch', note: 'team lunch', start_at: 100, end_at: 101, label_id: 3 }), + makeEvent({ uuid: 'other-label', title: 'Team retro', start_at: 200, end_at: 201, label_id: 6 }), + makeEvent({ uuid: 'too-late', title: 'Team party', start_at: 900, end_at: 901, label_id: 3 }), + ]; + }, + }); + + const result = parseToolText(await tool.handler({ + calendar_id: 123, + query: 'TEAM', + label_id: 3, + include_memos: false, + start_before: 500, + })); + + assert.equal(requestedId, '123'); + assert.equal(result.calendar_id, '123'); + assert.deepEqual(result.events.map((event) => event.uuid), ['early', 'late']); +}); + +test('calendar tools accept string calendar IDs from list_calendars', async () => { + let requestedId; + const tool = createGetCalendarLabelsTool({ + getCalendarLabels: async (calendarId) => { + requestedId = calendarId; + return []; + }, + }); + + const result = await tool.handler({ calendar_id: '123' }); + + assert.equal(result.isError, undefined); + assert.equal(requestedId, '123'); + assert.equal((await tool.handler({ calendar_id: 'abc' })).isError, true); +}); + +test('get_holidays returns sorted days and can exclude working-day observances', async () => { + let captured; + const tool = createGetHolidaysTool({ + getMemorialDays: async (countries, from, to) => { + captured = { countries, from: from.toISOString(), to: to.toISOString() }; + return [ + { id: 2, country_iso: 'KR', title: 'Hangul Day', workday: false, start_at: Date.UTC(2026, 9, 9), end_at: Date.UTC(2026, 9, 9) }, + { id: 3, country_iso: 'KR', title: 'Observance', workday: true, start_at: Date.UTC(2026, 9, 5), end_at: Date.UTC(2026, 9, 5) }, + { id: 1, country_iso: 'KR', title: 'Foundation Day', workday: false, start_at: Date.UTC(2026, 9, 3), end_at: Date.UTC(2026, 9, 3) }, + ]; + }, + }); + + const result = parseToolText(await tool.handler({ + country_iso: ['kr', 'KR'], + start_date: '2026-10-01', + end_date: '2026-10-31', + days_off_only: true, + })); + + assert.deepEqual(captured, { countries: ['KR'], from: '2026-10-01T00:00:00.000Z', to: '2026-11-01T00:00:00.000Z' }); + assert.deepEqual(result.holidays.map((day) => [day.date, day.title]), [ + ['2026-10-03', 'Foundation Day'], + ['2026-10-09', 'Hangul Day'], + ]); + const tooLong = await tool.handler({ country_iso: ['KR'], start_date: '2026-01-01', end_date: '2029-01-01' }); + assert.equal(tooLong.isError, true); +});