From 2c26310e04e323940ff67fb05eb202a288345816 Mon Sep 17 00:00:00 2001 From: itTkm <61522301+itTkm@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:25:01 +0900 Subject: [PATCH] feat: read images attached to event comments --- CHANGELOG.md | 4 + COMMANDS.md | 21 +++ README.ja.md | 5 +- README.ko.md | 5 +- README.md | 5 +- docs/FEATURE_CANDIDATES.md | 4 +- mcpb/manifest.json | 3 +- src/client/api.ts | 15 +++ src/config/config.ts | 1 + src/tools/comment-tools.ts | 90 ++++++++++++- src/tools/index.ts | 6 +- src/types/timetree.ts | 12 +- src/utils/comment-image.ts | 57 +++++++++ tests/comment-images.test.mjs | 233 ++++++++++++++++++++++++++++++++++ tests/tool-metadata.test.mjs | 2 + tests/tools.test.mjs | 1 + 16 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 src/utils/comment-image.ts create mode 100644 tests/comment-images.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 99636ce..75eddd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `get_event_comment_image` retrieves one image from an event comment, including images posted by free accounts, and returns standard MCP image content over stdio without saving files. Supports JPEG, PNG, GIF, and WebP up to 5 MiB, with a 30-second download timeout, no redirects, and no authentication material sent to the image host. +- `list_event_comments` and comment write results now include an `images` array with image indices, filenames, and dimensions, including comments containing only an image. Image download locations are not exposed. + ## [0.6.0] - 2026-10-18 ### Added diff --git a/COMMANDS.md b/COMMANDS.md index 0c86987..673eef0 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -23,6 +23,7 @@ Quick reference for using TimeTree MCP Server with AI assistants. | **delete_memo** | Delete a memo | | **add_event_comment** | Add an event comment | | **list_event_comments** | List event comments | +| **get_event_comment_image** | View one image attached to an event comment | | **update_event_comment** | Update an event comment | | **delete_event_comment** | Delete an event comment | | **get_calendar_labels** | Get calendar labels | @@ -254,9 +255,29 @@ Comments are visible to everyone in the calendar. TimeTree lets only a comment's |------|----------------| | `add_event_comment` | `calendar_id`, `event_uuid`, `content`, optional `silent` | | `list_event_comments` | `calendar_id`, `event_uuid` | +| `get_event_comment_image` | `calendar_id`, `event_uuid`, `comment_id`, optional `image_index` (default `0`) | | `update_event_comment` | `calendar_id`, `event_uuid`, `comment_id`, `content` | | `delete_event_comment` | `calendar_id`, `event_uuid`, `comment_id` | +`list_event_comments` includes image-only comments. Each comment has an `images` array (empty for text-only comments), with `image_index` (starting at `0`), `filename`, `width`, and `height`. Missing metadata is `null`; dimensions may be numbers or strings. Image locations are not returned. + +To view an image, pass its `comment_id` and `image_index` to `get_event_comment_image`: + +```json +{ + "calendar_id": "123", + "event_uuid": "example-event", + "comment_id": "example-comment", + "image_index": 0 +} +``` + +The result contains JSON metadata (`calendar_id`, `event_uuid`, `comment_id`, `image_index`, `filename`, `width`, `height`, `mime_type`, `byte_size`) as text and `structuredContent`, followed by an MCP `image` content block with base64 data. This works over stdio; the client must support image tool results and image understanding. The server holds the image in memory and does not save a local file or return a download URL. + +Images are fetched one at a time after checking the event's current comments, without sending session cookies or CSRF tokens to the image host. Downloads time out after 30 seconds, reject redirects, and accept JPEG, PNG, GIF, and WebP up to 5 MiB (before base64 encoding). Other formats and larger images return a tool error; images are not resized or converted. Comment images posted by free accounts are supported. Premium files attached directly to an event and image uploads are not supported. + +Example prompt: "Read the image in the comments on my appointment and summarize it." + --- ### calendar metadata tools diff --git a/README.ja.md b/README.ja.md index 6db9aea..859528d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -36,6 +36,7 @@ AI アシスタントに次のように聞くだけです。 ## できること - **カレンダーを読む。** 日付、キーワード、ラベルで予定を絞り込み、繰り返し予定は実際の日付に展開します。 +- **コメントの画像を読む。** 無料会員が投稿した画像も含め、予定のコメントに添付された画像を AI クライアントに渡せます。 - **頼めば変更する。** 予定、メモ、コメントの作成、更新、コピー、削除と、ラベル名や色の変更ができます。繰り返し予定は1回分だけ、またはその回以降をまとめて変更・削除できます。 - **バーチャルメンバーを管理する。** 子どもやペットなど TimeTree アカウントのないメンバーを追加、変更、削除できます。 - **変更を把握する。** ある時点以降に変わった予定と、最近だれが何を変えたかを確認できます。 @@ -118,13 +119,15 @@ GUI クライアントが `node` を見つけられない場合は、`command -v | 予定 | `get_events`, `get_updated_events`, `create_event`, `update_event`, `delete_event`, `copy_event` | | 繰り返し予定 | `update_event_occurrence`, `delete_event_occurrence` | | メモ | `list_memos`, `create_memo`, `update_memo`, `delete_memo` | -| コメント | `list_event_comments`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | +| コメント | `list_event_comments`, `get_event_comment_image`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | | ラベルとメンバー | `get_calendar_labels`, `update_calendar_labels`, `get_calendar_members`, `get_calendar_virtual_members`, `create_virtual_member`, `update_virtual_member`, `delete_virtual_member` | | その他 | `get_holidays`, `get_recent_activity` | `create_calendar` は名前(1〜20文字)と用途の明示を必須とします。メンバー招待は行わず、タイムアウト・サーバーエラーでは再試行しません。 作成後の応答を検証できない場合は、再実行前に既存のカレンダーを確認してください。 +`list_event_comments` は添付画像の情報も返します。`get_event_comment_image` で1枚ずつ取得し、stdio 経由で MCP 標準の画像データとして返します。クライアント側がツールの画像応答に対応している必要があります。JPEG・PNG・GIF・WebP、最大 5 MiB に対応し、画像はメモリにだけ保持してディスクには保存しません。予定に直接添付されたプレミアムのファイルと画像のアップロードは対象外です。 + パラメータと使用例: [COMMANDS.md](COMMANDS.md) ## プライバシーとセキュリティ diff --git a/README.ko.md b/README.ko.md index 3e012c9..0fa90c1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -36,6 +36,7 @@ AI 어시스턴트에게 이렇게 물어보면 됩니다. ## 할 수 있는 일 - **캘린더를 읽습니다.** 날짜, 키워드, 라벨로 일정을 거르고, 반복 일정은 실제 날짜로 펼쳐서 보여줍니다. +- **댓글 이미지를 읽습니다.** 무료 회원이 올린 이미지를 포함해 일정 댓글에 첨부된 이미지를 AI 클라이언트에 전달합니다. - **요청하면 바꿉니다.** 일정, 메모, 댓글을 만들고 고치고 복사하고 지우며, 라벨 이름과 색도 바꿉니다. 반복 일정은 한 회차만, 또는 그 회차부터 이후 전부를 고치거나 지울 수 있습니다. - **가상 멤버를 관리합니다.** 아이나 반려동물처럼 TimeTree 계정이 없는 멤버를 추가하고 고치고 지웁니다. - **변경 사항을 알려줍니다.** 특정 시점 이후 바뀐 일정과 최근에 누가 무엇을 바꿨는지 확인합니다. @@ -118,13 +119,15 @@ GUI 클라이언트가 `node`를 찾지 못하면 `command -v node`로 나온 | 일정 | `get_events`, `get_updated_events`, `create_event`, `update_event`, `delete_event`, `copy_event` | | 반복 일정 | `update_event_occurrence`, `delete_event_occurrence` | | 메모 | `list_memos`, `create_memo`, `update_memo`, `delete_memo` | -| 댓글 | `list_event_comments`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | +| 댓글 | `list_event_comments`, `get_event_comment_image`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | | 라벨과 멤버 | `get_calendar_labels`, `update_calendar_labels`, `get_calendar_members`, `get_calendar_virtual_members`, `create_virtual_member`, `update_virtual_member`, `delete_virtual_member` | | 기타 | `get_holidays`, `get_recent_activity` | `create_calendar`에는 이름(1~20자)과 명시적인 용도가 필수입니다. 멤버를 초대하지 않으며 시간 초과나 서버 오류는 재시도하지 않습니다. 생성 응답을 검증할 수 없으면 다시 시도하기 전에 기존 캘린더를 확인하세요. +`list_event_comments`는 첨부 이미지 정보도 반환합니다. `get_event_comment_image`로 한 장씩 가져와 stdio를 통해 MCP 표준 이미지 데이터로 전달합니다. 클라이언트가 도구의 이미지 응답을 지원해야 합니다. JPEG·PNG·GIF·WebP, 최대 5 MiB를 지원하며 이미지는 메모리에만 두고 디스크에 저장하지 않습니다. 일정에 직접 첨부된 프리미엄 파일과 이미지 업로드는 지원하지 않습니다. + 파라미터와 사용 예시: [COMMANDS.md](COMMANDS.md) ## 개인정보와 보안 diff --git a/README.md b/README.md index 7eb7951..f41d90c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Ask your AI assistant things like: ## What it does - **Reads your calendars.** Events with date, keyword, and label filters. Recurring events are expanded into their actual dates. +- **Reads comment images.** Pass images attached to event comments to your AI client, including images posted by free accounts. - **Changes them when you ask.** Create, update, copy, and delete events, memos, and comments, and rename or recolor labels. Change or delete a single occurrence of a recurring event, or it and the ones after it. - **Manages virtual members.** Add, rename, or remove members without a TimeTree account, such as children or pets. - **Keeps you up to date.** See what changed since a given time, and who made recent changes. @@ -118,7 +119,7 @@ Details: [docs/UPDATING.md](docs/UPDATING.md). Changes: [CHANGELOG.md](CHANGELOG | Events | `get_events`, `get_updated_events`, `create_event`, `update_event`, `delete_event`, `copy_event` | | Recurring events | `update_event_occurrence`, `delete_event_occurrence` | | Memos | `list_memos`, `create_memo`, `update_memo`, `delete_memo` | -| Comments | `list_event_comments`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | +| Comments | `list_event_comments`, `get_event_comment_image`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | | Labels and members | `get_calendar_labels`, `update_calendar_labels`, `get_calendar_members`, `get_calendar_virtual_members`, `create_virtual_member`, `update_virtual_member`, `delete_virtual_member` | | Other | `get_holidays`, `get_recent_activity` | @@ -127,6 +128,8 @@ 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. +`list_event_comments` includes image metadata. Use `get_event_comment_image` to retrieve one image as standard MCP image content over stdio; the client must support image tool results. JPEG, PNG, GIF, and WebP up to 5 MiB are supported. Images stay in memory and are not saved to disk. Premium files attached directly to an event and image uploads are not supported. + ## Privacy and security - Your email and password are stored only in your MCP client configuration, or in Claude Desktop's extension settings. They are sent only to TimeTree. diff --git a/docs/FEATURE_CANDIDATES.md b/docs/FEATURE_CANDIDATES.md index a4f6129..1f8635b 100644 --- a/docs/FEATURE_CANDIDATES.md +++ b/docs/FEATURE_CANDIDATES.md @@ -4,6 +4,8 @@ TimeTree web app features that this server does not expose as tools yet, with wh How these were observed: reading the web app's JavaScript bundle (its generated API client and the in-app AI assistant's tool definitions) and calling the endpoints from a signed-in browser session against a test calendar with made-up data, then deleting the test data. Paths are relative to `https://timetreeapp.com/api`. Behavior can change without notice. +Comment image reading uses the web app's `attachment.images` entries (`object_key`, `filename`, `width`, `height`) and its fixed `https://attachments.timetreeapp.com/` prefix. This read path was checked against the public JavaScript bundle, covered with mock tests, and verified end to end in Codex over stdio: authenticated comment reads, JPEG retrieval as MCP image content, and reading text from the returned image. + ## Waiting for a decision These work, but each needs a decision before it becomes a tool. @@ -15,7 +17,7 @@ These work, but each needs a decision before it becomes a tool. | Join or leave an event | Add or remove yourself as an attendee | `POST` / `DELETE /v1/calendar/{cid}/event/{eid}/attendees` (no body) | Verified | `update_event` with `attendees` already covers this; a separate tool is only a convenience. | | Rename a calendar | Change a calendar's name (and badge image, and whether album photos keep their location) | `PUT /v1/calendar/{cid}` with `{"name": "..."}` | Verified (name) | Changes the name for every member of a shared calendar. `{"media_content_attach_location": null}` returned `-403`; badge needs an image upload first (see below). | | Calendar album | List, upload, and delete photos in a calendar's album | List: `GET /v1/calendars/{cid}/media_contents`. Upload: `POST .../media_contents/presigned_urls` with `{"files": [{"filename", "taken_at", "location_lat", "location_lon", "request_id"}]}`, then a form POST of the file to the returned S3 `presigned_post`, then poll `GET .../media_contents/{uuid}/status` until `complete`. Delete: `DELETE .../media_contents` with `{"media_content_uuids": [...]}` | Verified end to end | Upload needs a local file path as tool input, and there is a monthly upload limit (error `-607`). Listing alone could ship first. | -| Image in a comment | Attach a photo to an event comment | `POST /v1/attachments` as multipart with `type=calendar`, `id={cid}`, `files[]=` returns `{"attachments": [{"object_key", "filename", "width", "height"}]}`; then `POST /v1/calendar/{cid}/event/{eid}/activity` with `{"id", "attachment": {"images": }, "silent": true}` | Verified | Same local-file input question as the album. | +| Upload an image in a comment | Attach a photo to an event comment | `POST /v1/attachments` as multipart with `type=calendar`, `id={cid}`, `files[]=` returns `{"attachments": [{"object_key", "filename", "width", "height"}]}`; then `POST /v1/calendar/{cid}/event/{eid}/activity` with `{"id", "attachment": {"images": }, "silent": true}` | Verified | Same local-file input question as the album. Reading existing comment images is supported by `list_event_comments` and `get_event_comment_image`. | | Batch create, update, delete | Several events in one call | No dedicated endpoint; the web app's assistant loops over the single-event endpoints | Not built | A wrong call affects many events at once; needs confirmation behavior. | | Find free time | Free slots for one or more members in a range | No endpoint; computed from events | Not built | Needs a definition of working hours and of which events block time. | diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 1ba6206..4e3fb19 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -46,7 +46,8 @@ { "name": "create_memo", "description": "Create a memo." }, { "name": "update_memo", "description": "Change a memo." }, { "name": "delete_memo", "description": "Delete a memo." }, - { "name": "list_event_comments", "description": "List comments on an event." }, + { "name": "list_event_comments", "description": "List comments and their attached image metadata." }, + { "name": "get_event_comment_image", "description": "View an image attached to an event comment." }, { "name": "add_event_comment", "description": "Comment on an event." }, { "name": "update_event_comment", "description": "Edit a comment." }, { "name": "delete_event_comment", "description": "Delete a comment." }, diff --git a/src/client/api.ts b/src/client/api.ts index 9409b8c..b392044 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -7,6 +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 { downloadCommentImage } from '../utils/comment-image.js'; import { AuthenticationError, TimeTreeAuthManager } from './auth.js'; import type { Calendar, @@ -1122,6 +1123,20 @@ export class TimeTreeAPIClient { } } + /** Resolve an image through the event's current comments before downloading it. */ + async getEventCommentImage(calendarId: string, eventUuid: string, commentId: string, imageIndex: number) { + if (!Number.isSafeInteger(imageIndex) || imageIndex < 0) { + throw new TimeTreeAPIError('image_index must be a non-negative integer.', 400); + } + const comments = await this.listEventComments(calendarId, eventUuid); + const comment = comments.find((candidate) => candidate.id === commentId); + if (!comment) throw new TimeTreeAPIError('Comment not found.', 404); + const image = comment.attachment?.images?.[imageIndex]; + if (!image) throw new TimeTreeAPIError('Image not found. Check images in list_event_comments.', 404); + const downloaded = await this.rateLimiter.executeWithRetry(() => downloadCommentImage(image.object_key)); + return { image, ...downloaded }; + } + async updateEventComment( calendarId: string, eventUuid: string, diff --git a/src/config/config.ts b/src/config/config.ts index 4438a04..0f1b589 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -27,6 +27,7 @@ export const TIMETREE_CONFIG = { // Base URLs for TimeTree APIs. BASE_URL: 'https://timetreeapp.com/api/v1', V2_BASE_URL: 'https://timetreeapp.com/api/v2', + ATTACHMENT_BASE_URL: 'https://attachments.timetreeapp.com/', // API Endpoints. ENDPOINTS: { diff --git a/src/tools/comment-tools.ts b/src/tools/comment-tools.ts index 82bf2f4..85f743b 100644 --- a/src/tools/comment-tools.ts +++ b/src/tools/comment-tools.ts @@ -15,7 +15,7 @@ import { DESTRUCTIVE_IDEMPOTENT, READ_ONLY, } from './shared-schemas.js'; -import type { EventActivity } from '../types/timetree.js'; +import type { CommentImage, EventActivity } from '../types/timetree.js'; export const AddEventCommentInputSchema = z.object({ calendar_id: CalendarIdSchema.describe('The calendar ID'), @@ -29,6 +29,11 @@ export const ListEventCommentsInputSchema = z.object({ event_uuid: z.string().describe('The event UUID'), }); +export const GetEventCommentImageInputSchema = ListEventCommentsInputSchema.extend({ + comment_id: z.string().min(1).describe('The comment ID from list_event_comments'), + image_index: z.number().int().nonnegative().default(0).describe('Image index from list_event_comments (default: 0)'), +}); + export const UpdateEventCommentInputSchema = z.object({ calendar_id: CalendarIdSchema.describe('The calendar ID'), event_uuid: z.string().describe('The event UUID'), @@ -44,6 +49,17 @@ export const DeleteEventCommentInputSchema = z.object({ const nullable = (type: string) => ({ type: [type, 'null'] }); +const COMMENT_IMAGE_OUTPUT_SCHEMA = { + type: 'object', + properties: { + image_index: { type: 'integer', minimum: 0 }, + filename: nullable('string'), + width: { type: ['number', 'string', 'null'] }, + height: { type: ['number', 'string', 'null'] }, + }, + required: ['image_index', 'filename', 'width', 'height'], +} as const; + const COMMENT_OUTPUT_SCHEMA = { type: 'object', properties: { @@ -51,13 +67,14 @@ const COMMENT_OUTPUT_SCHEMA = { event_id: nullable('string'), calendar_id: nullable('number'), content: { type: 'string' }, + images: { type: 'array', items: COMMENT_IMAGE_OUTPUT_SCHEMA }, author_id: { type: ['number', 'null'], description: 'User ID of the author' }, author_type: nullable('string'), editor_id: nullable('number'), created_at: nullable('string'), updated_at: nullable('string'), }, - required: ['id', 'content'], + required: ['id', 'content', 'images'], } as const; const COMMENT_RESULT_OUTPUT_SCHEMA = { @@ -66,12 +83,17 @@ const COMMENT_RESULT_OUTPUT_SCHEMA = { required: ['success', 'comment'], } as const; +function formatCommentImage(image: CommentImage, imageIndex: number) { + return { image_index: imageIndex, filename: image.filename ?? null, width: image.width ?? null, height: image.height ?? null }; +} + function formatComment(comment: EventActivity) { return { id: comment.id, event_id: comment.event_id || null, calendar_id: comment.calendar_id || null, content: comment.attachment?.content || '', + images: (comment.attachment?.images ?? []).map(formatCommentImage), author_id: comment.author_id || null, author_type: comment.author_type || null, editor_id: comment.editor_id || null, @@ -102,7 +124,7 @@ function errorContent(error: string, message: string, details?: unknown) { function commentError(error: unknown, action: string) { if (error instanceof TimeTreeAPIError && error.statusCode === 404) { - return errorContent('Not found', 'The specified event or comment could not be found.'); + return errorContent('Not found', 'The specified event, comment, or image could not be found. Check list_event_comments for current comments and image indices.'); } if (error instanceof TimeTreeAPIError && error.statusCode === 403) { @@ -175,7 +197,7 @@ export function createListEventCommentsTool(apiClient: TimeTreeAPIClient) { return { name: 'list_event_comments', description: - 'List the comments on an event with their ids and author user IDs. Only comments are returned, ' + + 'List comments with their ids, author user IDs, and attached image metadata. Use get_event_comment_image to view an image. Only comments are returned, ' + 'not change history; use get_recent_activity for who changed what.', annotations: READ_ONLY, outputSchema: { @@ -228,6 +250,66 @@ export function createListEventCommentsTool(apiClient: TimeTreeAPIClient) { }; } +/** Return images directly so MCP clients do not need access to the server's filesystem. */ +export function createGetEventCommentImageTool(apiClient: TimeTreeAPIClient) { + return { + name: 'get_event_comment_image', + description: + 'View one image attached to an event comment, including images posted by free accounts. ' + + 'Get comment_id and image_index from list_event_comments. Returns image content directly, without saving a file. ' + + 'Supports JPEG, PNG, GIF, and WebP up to 5 MiB. Premium event file attachments are not supported.', + annotations: READ_ONLY, + outputSchema: { + type: 'object', + properties: { + calendar_id: { type: 'number' }, + event_uuid: { type: 'string' }, + comment_id: { type: 'string' }, + ...COMMENT_IMAGE_OUTPUT_SCHEMA.properties, + mime_type: { type: 'string' }, + byte_size: { type: 'integer' }, + }, + required: ['calendar_id', 'event_uuid', 'comment_id', ...COMMENT_IMAGE_OUTPUT_SCHEMA.required, 'mime_type', 'byte_size'], + }, + inputSchema: { + type: 'object', + properties: { + calendar_id: CALENDAR_ID_JSON_SCHEMA, + event_uuid: { type: 'string', description: 'The event UUID' }, + comment_id: { type: 'string', description: 'The comment ID from list_event_comments', minLength: 1 }, + image_index: { type: 'integer', minimum: 0, default: 0, description: 'Image index from list_event_comments (default: 0)' }, + }, + required: ['calendar_id', 'event_uuid', 'comment_id'], + }, + handler: async (args: unknown) => { + try { + const input = GetEventCommentImageInputSchema.parse(args); + logger.info('Tool: get_event_comment_image called', { + calendar_id: input.calendar_id, event_uuid: input.event_uuid, comment_id: input.comment_id, image_index: input.image_index, + }); + const { image, data, mimeType } = await apiClient.getEventCommentImage( + input.calendar_id.toString(), input.event_uuid, input.comment_id, input.image_index + ); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + calendar_id: input.calendar_id, event_uuid: input.event_uuid, comment_id: input.comment_id, + ...formatCommentImage(image, input.image_index), mime_type: mimeType, byte_size: data.byteLength, + }, null, 2), + }, + { type: 'image', data: data.toString('base64'), mimeType }, + ], + }; + } catch (error) { + logger.error('Tool: get_event_comment_image failed', { error }); + return commentError(error, 'get event comment image'); + } + }, + }; +} + export function createUpdateEventCommentTool(apiClient: TimeTreeAPIClient) { return { name: 'update_event_comment', diff --git a/src/tools/index.ts b/src/tools/index.ts index 4f07948..d278b72 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -17,6 +17,7 @@ import { import { createAddEventCommentTool, createDeleteEventCommentTool, + createGetEventCommentImageTool, createListEventCommentsTool, createUpdateEventCommentTool, } from './comment-tools.js'; @@ -56,6 +57,7 @@ export function registerTools(apiClient: TimeTreeAPIClient) { createDeleteMemoTool(apiClient), createAddEventCommentTool(apiClient), createListEventCommentsTool(apiClient), + createGetEventCommentImageTool(apiClient), createUpdateEventCommentTool(apiClient), createDeleteEventCommentTool(apiClient), createGetCalendarLabelsTool(apiClient), @@ -74,11 +76,11 @@ export function registerTools(apiClient: TimeTreeAPIClient) { * Tools that declare an outputSchema also return their JSON as structuredContent, which * MCP requires to match that schema. Error results carry no structured content. */ -export function toolResult; isError?: boolean }>( +export function toolResult; isError?: boolean }>( tool: { outputSchema?: unknown }, result: T ): T & { structuredContent?: Record } { - if (!tool.outputSchema || result.isError || result.content[0]?.type !== 'text') { + if (!tool.outputSchema || result.isError || result.content[0]?.type !== 'text' || typeof result.content[0].text !== 'string') { return result; } return { ...result, structuredContent: JSON.parse(result.content[0].text) }; diff --git a/src/types/timetree.ts b/src/types/timetree.ts index bbc666d..3177cb7 100644 --- a/src/types/timetree.ts +++ b/src/types/timetree.ts @@ -156,6 +156,15 @@ export const EventSchema = z.object({ export type Event = z.infer; +export const CommentImageSchema = z.object({ + object_key: z.string().min(1), + filename: z.string().optional().nullable(), + width: z.union([z.number(), z.string()]).optional().nullable(), + height: z.union([z.number(), z.string()]).optional().nullable(), +}).passthrough(); + +export type CommentImage = z.infer; + export const EventActivitySchema = z.object({ id: z.string(), event_id: z.string().optional(), @@ -165,7 +174,8 @@ export const EventActivitySchema = z.object({ author_type: z.string().optional(), editor_id: z.number().optional(), attachment: z.object({ - content: z.string().optional(), + content: z.string().optional().nullable(), + images: z.array(CommentImageSchema).optional().nullable(), items: z.array(z.any()).optional(), }).passthrough().optional().nullable(), deactivated_at: z.number().optional().nullable(), diff --git a/src/utils/comment-image.ts b/src/utils/comment-image.ts new file mode 100644 index 0000000..e782c73 --- /dev/null +++ b/src/utils/comment-image.ts @@ -0,0 +1,57 @@ +import { TIMETREE_CONFIG } from '../config/config.js'; +import { HttpError } from './http-client.js'; + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; + +/** Download without the API client's cookies or CSRF token, including on redirects. */ +export async function downloadCommentImage(objectKey: string): Promise<{ data: Buffer; mimeType: string }> { + const url = new URL(TIMETREE_CONFIG.ATTACHMENT_BASE_URL); + // Setting only the pathname keeps even a malformed object key on the fixed host. + url.pathname = objectKey; + + try { + const response = await fetch(url, { + credentials: 'omit', + redirect: 'error', + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new HttpError(response.status, `Image download failed (HTTP ${response.status}).`); + } + if (Number(response.headers.get('content-length')) > MAX_IMAGE_BYTES) { + await response.body?.cancel(); + throw new HttpError(413, 'Comment images must be no larger than 5 MiB.'); + } + if (!response.body) throw new HttpError(415, 'The comment image is empty.'); + + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.byteLength; + if (size > MAX_IMAGE_BYTES) { + throw new HttpError(413, 'Comment images must be no larger than 5 MiB.'); + } + chunks.push(chunk); + } + const data = Buffer.concat(chunks, size); + // Identify bytes rather than trusting a filename or a CDN Content-Type header. + let mimeType: string; + if (data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + mimeType = 'image/png'; + } else if (data.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) { + mimeType = 'image/jpeg'; + } else if (['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString('latin1'))) { + mimeType = 'image/gif'; + } else if (data.subarray(0, 4).toString('latin1') === 'RIFF' && data.subarray(8, 12).toString('latin1') === 'WEBP') { + mimeType = 'image/webp'; + } else { + throw new HttpError(415, 'Unsupported comment image. Supported formats: JPEG, PNG, GIF, WebP.'); + } + return { data, mimeType }; + } catch (error) { + if (error instanceof HttpError) throw error; + // Fetch errors can contain private object keys or response details. + throw new Error('Could not download the comment image. The request failed, timed out, or was redirected.'); + } +} diff --git a/tests/comment-images.test.mjs b/tests/comment-images.test.mjs new file mode 100644 index 0000000..df9ad8a --- /dev/null +++ b/tests/comment-images.test.mjs @@ -0,0 +1,233 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { TimeTreeAPIClient } from '../.test-dist/client/api.js'; +import { downloadCommentImage } from '../.test-dist/utils/comment-image.js'; +import { createGetEventCommentImageTool, createListEventCommentsTool } from '../.test-dist/tools/comment-tools.js'; +import { toolResult } from '../.test-dist/tools/index.js'; + +const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII=', 'base64'); +const image = { object_key: 'calendar/example.png', filename: 'example.png', width: 1, height: '1', private_field: 'hidden' }; +const comment = { id: 'image-comment', type: 0, attachment: { content: null, images: [image] } }; +const args = { calendar_id: '123', event_uuid: 'event-1', comment_id: comment.id }; +const makeClient = (activities = [comment]) => new TimeTreeAPIClient({ + isAuthenticated: () => true, + getHttpClient: () => ({ get: async () => ({ event_activities: activities }) }), +}); + +test('comment listing includes image-only comments and bounded metadata without download locations', async () => { + const tool = createListEventCommentsTool(makeClient([ + comment, + { id: 'text-comment', type: 0, attachment: { content: 'hello' } }, + { id: 'null-images', type: 0, attachment: { images: null } }, + { id: 'deleted', type: 0, attachment: { images: [image] }, deactivated_at: 1 }, + { id: 'system', type: 1, attachment: { images: [image] } }, + ])); + const result = toolResult(tool, await tool.handler(args)); + assert.equal(result.isError, undefined); + assert.equal(result.structuredContent.total, 3); + assert.equal(result.structuredContent.comments[0].content, ''); + assert.deepEqual(result.structuredContent.comments[0].images, [ + { image_index: 0, filename: 'example.png', width: 1, height: '1' }, + ]); + assert.deepEqual(result.structuredContent.comments[1].images, []); + assert.deepEqual(result.structuredContent.comments[2].images, []); + assert.doesNotMatch(JSON.stringify(result), /object_key|calendar\/example|private_field|hidden/); +}); + +test('image tool resolves an authorized comment, rate limits both reads, and returns valid MCP image content', async (t) => { + const client = makeClient(); + let requests = 0; + t.mock.method(client.rateLimiter, 'executeWithRetry', async (fn) => { requests++; return fn(); }); + t.mock.method(globalThis, 'fetch', async (url, options) => { + assert.equal(url.href, 'https://attachments.timetreeapp.com/calendar/example.png'); + assert.equal(options.credentials, 'omit'); + assert.equal(options.redirect, 'error'); + assert.equal(options.headers, undefined); + assert.ok(options.signal instanceof AbortSignal); + return new Response(png, { headers: { 'content-type': 'application/octet-stream' } }); + }); + const tool = createGetEventCommentImageTool(client); + const result = toolResult(tool, await tool.handler(args)); + assert.equal(result.isError, undefined); + assert.equal(requests, 2); + assert.deepEqual(result.structuredContent, { + calendar_id: 123, event_uuid: 'event-1', comment_id: comment.id, + image_index: 0, filename: 'example.png', width: 1, height: '1', mime_type: 'image/png', byte_size: png.length, + }); + assert.deepEqual(result.content[1], { type: 'image', data: png.toString('base64'), mimeType: 'image/png' }); + assert.ok(CallToolResultSchema.safeParse(result).success); + assert.doesNotMatch(JSON.stringify(result.structuredContent), /object_key|calendar\/example|private_field/); +}); + +test('an explicit image index selects the requested attachment', async (t) => { + const client = makeClient([{ ...comment, attachment: { images: [image, { object_key: 'calendar/second.png' }] } }]); + t.mock.method(globalThis, 'fetch', async (url) => { + assert.equal(url.pathname, '/calendar/second.png'); + return new Response(png); + }); + const tool = createGetEventCommentImageTool(client); + const result = toolResult(tool, await tool.handler({ ...args, image_index: 1 })); + assert.equal(result.isError, undefined); + assert.equal(result.structuredContent.image_index, 1); + assert.equal(result.structuredContent.filename, null); + assert.equal(result.structuredContent.width, null); +}); + +test('missing, deleted, and system comments and missing image indices never trigger a download', async (t) => { + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected download'); }); + for (const [activities, commentId, index] of [ + [[], comment.id, 0], + [[{ ...comment, deactivated_at: 1 }], comment.id, 0], + [[{ ...comment, type: 1 }], comment.id, 0], + [[comment], 'another-comment', 0], + [[comment], comment.id, 1], + [[{ ...comment, attachment: null }], comment.id, 0], + ]) { + await assert.rejects(makeClient(activities).getEventCommentImage('123', 'event-1', commentId, index), { statusCode: 404 }); + } + assert.equal(fetchMock.mock.callCount(), 0); +}); + +test('image inputs reject invalid indices and missing identifiers before reading comments', async () => { + let reads = 0; + const tool = createGetEventCommentImageTool({ getEventCommentImage: async () => { reads++; } }); + for (const invalid of [ + { ...args, image_index: -1 }, { ...args, image_index: 0.5 }, { ...args, image_index: '0' }, + { ...args, comment_id: '' }, { ...args, calendar_id: 'bad' }, {}, + ]) { + const result = toolResult(tool, await tool.handler(invalid)); + assert.equal(result.isError, true); + assert.equal(result.structuredContent, undefined); + } + assert.equal(reads, 0); +}); + +test('failed calendar access does not download an attachment', async (t) => { + const client = new TimeTreeAPIClient({ + isAuthenticated: () => true, + getHttpClient: () => ({ get: async () => { throw new Error('access denied'); } }), + }); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected download'); }); + await assert.rejects(client.getEventCommentImage('123', 'event-1', comment.id, 0), /access denied/); + assert.equal(fetchMock.mock.callCount(), 0); +}); + +test('the download host cannot be changed by an object key', async (t) => { + const keys = ['//localhost/private.png', 'https://example.com/private.png', '\\localhost\\private.png', 'file:///private.png']; + t.mock.method(globalThis, 'fetch', async (url) => { + assert.equal(url.origin, 'https://attachments.timetreeapp.com'); + assert.equal(url.search, ''); + assert.equal(url.hash, ''); + return new Response(png); + }); + for (const key of keys) await downloadCommentImage(key); +}); + +test('supported image types are identified from bytes rather than names or headers', async (t) => { + const samples = [ + [png, 'image/png'], + [Buffer.from([255, 216, 255, 224]), 'image/jpeg'], + [Buffer.from('GIF87a'), 'image/gif'], + [Buffer.from('GIF89a'), 'image/gif'], + [Buffer.from('RIFF1234WEBP'), 'image/webp'], + ]; + for (const [bytes, expected] of samples) { + t.mock.method(globalThis, 'fetch', async () => new Response(bytes)); + const result = await downloadCommentImage('calendar/image.unknown'); + assert.equal(result.mimeType, expected); + assert.deepEqual(result.data, bytes); + t.mock.restoreAll(); + } +}); + +test('empty, SVG, and HTML responses fail even when labeled as PNG', async (t) => { + for (const body of ['', '', 'sign in']) { + t.mock.method(globalThis, 'fetch', async () => new Response(body, { headers: { 'content-type': 'image/png' } })); + await assert.rejects(downloadCommentImage('calendar/image.png'), { statusCode: 415 }); + t.mock.restoreAll(); + } +}); + +test('oversized Content-Length cancels the body without reading it', async (t) => { + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => new Response(new ReadableStream({ + cancel() { cancelled = true; }, + }), { headers: { 'content-length': String(5 * 1024 * 1024 + 1) } })); + await assert.rejects(downloadCommentImage('calendar/image.png'), { statusCode: 413 }); + assert.equal(cancelled, true); +}); + +test('streamed image size is bounded even with a missing or misleading Content-Length', async (t) => { + for (const headers of [{}, { 'content-length': '1' }]) { + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(1024 * 1024)); }, + cancel() { cancelled = true; }, + }), { headers })); + await assert.rejects(downloadCommentImage('calendar/image.png'), { statusCode: 413 }); + assert.equal(cancelled, true); + t.mock.restoreAll(); + } +}); + +for (const stage of ['headers', 'body']) { + test(`comment image deadline aborts stalled response ${stage}`, { timeout: 3000 }, async (t) => { + const controller = new AbortController(); + const reason = new DOMException('Test download deadline reached', 'TimeoutError'); + const deadline = t.mock.method(AbortSignal, 'timeout', () => controller.signal); + const server = createServer((_request, response) => { + if (stage === 'headers') { + controller.abort(reason); + } else { + response.writeHead(200, { 'content-type': 'image/png' }); + response.write(png.subarray(0, 8)); + } + }); + t.after(() => { + server.closeAllConnections(); + return new Promise((resolve) => server.close(resolve)); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + const fetchImage = globalThis.fetch; + let receivedHeaders = false; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + const response = await fetchImage(`http://127.0.0.1:${server.address().port}/`, options); + receivedHeaders = true; + setImmediate(() => controller.abort(reason)); + return response; + }); + const tool = createGetEventCommentImageTool(makeClient()); + const result = toolResult(tool, await tool.handler(args)); + assert.equal(result.isError, true); + assert.equal(result.structuredContent, undefined); + assert.ok(result.content.every((content) => content.type === 'text')); + assert.match(result.content[0].text, /Could not download the comment image/); + assert.equal(receivedHeaders, stage === 'body'); + assert.equal(controller.signal.aborted, true); + assert.deepEqual(deadline.mock.calls.map((call) => call.arguments), [[30_000]]); + }); +} + +test('HTTP and network errors never return upstream bodies or image locations', async (t) => { + t.mock.method(globalThis, 'fetch', async () => new Response('private upstream body', { status: 403 })); + await assert.rejects(downloadCommentImage('calendar/private-key.png'), { statusCode: 403, message: 'Image download failed (HTTP 403).' }); + t.mock.restoreAll(); + for (const name of ['TypeError', 'TimeoutError']) { + t.mock.method(globalThis, 'fetch', async () => { + const error = new Error('https://attachments.timetreeapp.com/calendar/private-key.png'); + error.name = name; + throw error; + }); + const tool = createGetEventCommentImageTool(makeClient()); + const result = toolResult(tool, await tool.handler(args)); + assert.equal(result.isError, true); + assert.equal(result.structuredContent, undefined); + assert.doesNotMatch(JSON.stringify(result), /private-key|attachments\.timetreeapp|private upstream body/); + t.mock.restoreAll(); + } +}); diff --git a/tests/tool-metadata.test.mjs b/tests/tool-metadata.test.mjs index 063e83e..03ab603 100644 --- a/tests/tool-metadata.test.mjs +++ b/tests/tool-metadata.test.mjs @@ -55,6 +55,7 @@ const fakeClient = { deleteMemo: async () => {}, addEventComment: async () => comment, listEventComments: async () => [comment], + getEventCommentImage: async () => ({ image: { object_key: 'calendar/example.png', filename: 'example.png', width: 1, height: 1 }, data: Buffer.from('image'), mimeType: 'image/png' }), updateEventComment: async () => comment, deleteEventComment: async () => {}, getCalendarLabels: async () => [label], @@ -89,6 +90,7 @@ const sampleArgs = { delete_memo: { calendar_id: '123', memo_uuid: 'memo-1' }, add_event_comment: { calendar_id: '123', event_uuid: 'evt-1', content: 'hello' }, list_event_comments: { calendar_id: '123', event_uuid: 'evt-1' }, + get_event_comment_image: { calendar_id: '123', event_uuid: 'evt-1', comment_id: 'c-1' }, update_event_comment: { calendar_id: '123', event_uuid: 'evt-1', comment_id: 'c-1', content: 'edited' }, delete_event_comment: { calendar_id: '123', event_uuid: 'evt-1', comment_id: 'c-1' }, get_calendar_labels: { calendar_id: '123' }, diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index ceeb5ad..e46689b 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -54,6 +54,7 @@ test('registerTools exposes every tool', () => { 'get_calendar_members', 'get_calendar_virtual_members', 'get_current_user', + 'get_event_comment_image', 'get_events', 'get_holidays', 'get_recent_activity',