Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"

---

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Claude、Codex、Cursor、Windsurf などの MCP クライアントから、Time
- 🗒️ **メモ管理** - TimeTree メモの一覧取得、作成、更新、削除
- 💬 **コメント管理** - イベントコメントの追加、一覧取得、更新、削除
- 🏷️ **カレンダーメタデータ** - ラベルの取得/更新、メンバー/仮想メンバーの確認
- 🎌 **祝日** - 国と期間を指定して祝日を取得
- 🔐 **安全な認証** - メールアドレス/パスワード認証(MCP 設定にのみ保存)
- ⚡ **レート制限** - Token Bucket アルゴリズムで API 負荷を抑制
- 🔄 **自動ページネーション** - 複数ページにまたがるイベントを自動取得
Expand Down Expand Up @@ -122,7 +123,7 @@ npm run build
### MCP ツール

- **list_calendars** - 参加ユーザー情報付きですべてのカレンダーを一覧表示
- **get_events** - 自動ページネーションでカレンダーのイベントを取得
- **get_events** - 自動ページネーションでカレンダーのイベントを取得(開始時刻順、キーワード/ラベル/期間フィルター)
- **get_updated_events** - 指定時刻以降に更新されたイベントを取得(効率的な差分同期)
- **create_event** - カレンダーに新しいイベントを作成(通知、繰り返し、参加者、チェックリストに対応)
- **update_event** - 既存イベントを更新
Expand All @@ -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) を参照してください。

Expand Down
4 changes: 3 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Claude, Codex, Cursor, Windsurf 같은 MCP 클라이언트에서 TimeTree 캘린
- 🗒️ **메모 관리** - TimeTree 메모 조회, 생성, 수정, 삭제
- 💬 **댓글 관리** - 이벤트 댓글 추가, 조회, 수정, 삭제
- 🏷️ **캘린더 메타데이터** - 라벨 조회/수정 및 멤버/가상 멤버 조회
- 🎌 **공휴일 조회** - 국가와 기간으로 공휴일 조회
- 🔐 **안전한 인증** - 이메일/비밀번호 인증 (MCP 설정에만 저장)
- ⚡ **속도 제한** - Token Bucket 알고리즘으로 API 과부하 방지
- 🔄 **자동 페이지네이션** - 여러 페이지에 걸친 모든 이벤트 자동 조회
Expand Down Expand Up @@ -122,7 +123,7 @@ npm run build
### MCP 도구

- **list_calendars** - 참여 중인 사용자와 함께 모든 캘린더 조회
- **get_events** - 자동 페이지네이션으로 캘린더 이벤트 조회
- **get_events** - 자동 페이지네이션으로 캘린더 이벤트 조회 (시작 시간순 정렬, 키워드/라벨/기간 필터)
- **get_updated_events** - 특정 시간 이후 업데이트된 이벤트 조회 (효율적인 증분 동기화)
- **create_event** - 캘린더에 새 이벤트 생성 (알림, 반복, 참석자, 체크리스트 지원)
- **update_event** - 기존 이벤트 수정
Expand All @@ -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) 참조

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
110 changes: 105 additions & 5 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type {
EventActivity,
EventActivityResponse,
EventsSyncResponse,
MemorialDay,
MemorialDaysResponse,
UpdateEventInput,
UpdateEventResponse,
UpdateMemoInput,
Expand All @@ -39,6 +41,7 @@ import {
EventActivitiesResponseSchema,
EventActivityResponseSchema,
EventsSyncResponseSchema,
MemorialDaysResponseSchema,
UpdateEventResponseSchema,
} from '../types/timetree.js';

Expand Down Expand Up @@ -77,6 +80,43 @@ function createClientUuid(): string {
return randomUUID().replace(/-/g, '');
}

type EventAttachmentInput = NonNullable<CreateEventInput['attachment']>;

/**
* 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;
Expand Down Expand Up @@ -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<MemorialDay[]> {
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<MemorialDaysResponse>(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[]
Expand Down Expand Up @@ -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<CreateEventResponse>(url, eventData, undefined, true);
.post<CreateEventResponse>(url, body, undefined, true);
});

const validated = CreateEventResponseSchema.parse(response);
Expand Down Expand Up @@ -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<UpdateEventResponse>(url, updateData, undefined, true);
.put<UpdateEventResponse>(url, body, undefined, true);
});

const validated = UpdateEventResponseSchema.parse(response);
Expand Down Expand Up @@ -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<Event> {
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<CreateEventResponse>(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<Event | null> {
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;
}
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading
Loading