diff --git a/CHANGELOG.md b/CHANGELOG.md index 0414cad..2454302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added `create_calendar` without member invitations, requiring an explicit name and purpose. Uses existing rate limiting and authentication recovery without retrying timeouts or server errors. +- Calendar creation distinguishes invalid fields and authentication failures without forwarding upstream details. + +### Fixed +- Calendar creation treats invalid success responses as uncertain outcomes instead of invalid user input, and reports persistent TimeTree session or CSRF rejections as authentication failures. +- Authentication recovery no longer retries HTTP 5xx responses, even when their bodies contain session or CSRF error codes. + +### Security +- Updated locked MCP SDK and proxy-addr dependencies to resolve high and critical audit findings. + ## [0.4.0] - 2026-10-03 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8a2fef8..9ad8be8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Important context: The server currently exposes tools for: -- Listing calendars. +- Listing and creating calendars (creation does not invite members). - Reading calendar events and updated events. - Creating, updating, and deleting events. - Managing event comments. diff --git a/COMMANDS.md b/COMMANDS.md index 880de72..3e1f632 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -291,3 +291,17 @@ Events can be color-coded with `label_id` 1-10: - **All-day events**: TimeTree uses inclusive end dates. A Feb 15-16 event sets `end_at` to Feb 16 00:00, not Feb 17. - **Timezones**: Default is UTC. Specify timezone for accurate local times. - **Write operations** (create/update/delete) require a CSRF token, which is managed automatically. + +### Create a calendar + +Call `create_calendar` with both `name` (1–20 characters) and an explicit `purpose`: + +```json +{"name": "Project calendar", "purpose": "work"} +``` + +Supported purposes: `family`, `private`, `lover`, `work`, `friend`, `work_schedule`, `lesson`, `school_event`, `circle`, `hobby`, `other`. +Creation does not invite members. Requests use rate limiting and retry HTTP 429 rejections or rejected authentication through the shared client. Timeouts and server errors are not retried. +Invalid input returns field-level reasons without input values. Authentication errors return `Authentication failed`. +Persistent TimeTree session or CSRF rejections are reported as authentication failures after one sign-in recovery attempt. +After an uncertain creation failure (such as a timeout or an unexpected success response), check existing calendars before retrying. Response validation failures are not reported as invalid input. HTTP 5xx responses are never retried, even if their bodies contain authentication error codes. diff --git a/README.ja.md b/README.ja.md index f8ac367..fe8503f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -113,13 +113,16 @@ GUI クライアントが `node` を見つけられない場合は、`command -v | 分類 | ツール | |---|---| -| カレンダー | `list_calendars` | +| カレンダー | `list_calendars`, `create_calendar` | | 予定 | `get_events`, `get_updated_events`, `create_event`, `update_event`, `delete_event` | | メモ | `list_memos`, `create_memo`, `update_memo`, `delete_memo` | | コメント | `list_event_comments`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | | ラベルとメンバー | `get_calendar_labels`, `update_calendar_labels`, `get_calendar_members`, `get_calendar_virtual_members` | | その他 | `get_holidays`, `get_recent_activity` | +`create_calendar` は名前(1〜20文字)と用途の明示を必須とします。メンバー招待は行わず、タイムアウト・サーバーエラーでは再試行しません。 +作成後の応答を検証できない場合は、再実行前に既存のカレンダーを確認してください。 + パラメータと使用例: [COMMANDS.md](COMMANDS.md) ## プライバシーとセキュリティ diff --git a/README.ko.md b/README.ko.md index 00e4b7b..bc8929e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -113,13 +113,16 @@ GUI 클라이언트가 `node`를 찾지 못하면 `command -v node`로 나온 | 영역 | 툴 | |---|---| -| 캘린더 | `list_calendars` | +| 캘린더 | `list_calendars`, `create_calendar` | | 일정 | `get_events`, `get_updated_events`, `create_event`, `update_event`, `delete_event` | | 메모 | `list_memos`, `create_memo`, `update_memo`, `delete_memo` | | 댓글 | `list_event_comments`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | | 라벨과 멤버 | `get_calendar_labels`, `update_calendar_labels`, `get_calendar_members`, `get_calendar_virtual_members` | | 기타 | `get_holidays`, `get_recent_activity` | +`create_calendar`에는 이름(1~20자)과 명시적인 용도가 필수입니다. 멤버를 초대하지 않으며 시간 초과나 서버 오류는 재시도하지 않습니다. +생성 응답을 검증할 수 없으면 다시 시도하기 전에 기존 캘린더를 확인하세요. + 파라미터와 사용 예시: [COMMANDS.md](COMMANDS.md) ## 개인정보와 보안 diff --git a/README.md b/README.md index 7120b43..0b6b9ea 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Details: [docs/UPDATING.md](docs/UPDATING.md). Changes: [CHANGELOG.md](CHANGELOG | Area | Tools | |---|---| -| Calendars | `list_calendars` | +| Calendars | `list_calendars`, `create_calendar` | | Events | `get_events`, `get_updated_events`, `create_event`, `update_event`, `delete_event` | | Memos | `list_memos`, `create_memo`, `update_memo`, `delete_memo` | | Comments | `list_event_comments`, `add_event_comment`, `update_event_comment`, `delete_event_comment` | @@ -122,6 +122,9 @@ Details: [docs/UPDATING.md](docs/UPDATING.md). Changes: [CHANGELOG.md](CHANGELOG Parameters and examples: [COMMANDS.md](COMMANDS.md) +`create_calendar` requires a name (1–20 characters) and an explicit purpose. It does not invite members. Timeouts and server errors are not retried. +If the creation response cannot be validated, check existing calendars before trying again. + ## Privacy and security - 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/mcpb/manifest.json b/mcpb/manifest.json index c6d9e7c..7637dd9 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -32,6 +32,7 @@ }, "tools": [ { "name": "list_calendars", "description": "List your TimeTree calendars." }, + { "name": "create_calendar", "description": "Create a calendar with an explicit name and purpose, without inviting members." }, { "name": "get_events", "description": "Read events with date, keyword, and label filters; expands recurring events." }, { "name": "get_updated_events", "description": "Find events changed after a point in time." }, { "name": "create_event", "description": "Create an event." }, diff --git a/package-lock.json b/package-lock.json index 64c3e9e..cabe830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -518,9 +518,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", - "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -2036,9 +2036,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -2046,6 +2046,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { diff --git a/src/client/api.ts b/src/client/api.ts index 0b25e2f..3648d64 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'crypto'; import { TIMETREE_CONFIG } from '../config/config.js'; import { RateLimiter } from '../utils/rate-limiter.js'; import { logger } from '../utils/logger.js'; -import { TimeTreeAuthManager } from './auth.js'; +import { AuthenticationError, TimeTreeAuthManager } from './auth.js'; import type { Calendar, CalendarLabel, @@ -36,6 +36,7 @@ import type { } from '../types/timetree.js'; import { CalendarLabelsResponseSchema, + CalendarSchema, CalendarUsersResponseSchema, CalendarVirtualUsersResponseSchema, CalendarsResponseSchema, @@ -129,7 +130,10 @@ function isRequestShapeRejected(error: unknown): boolean { /** Signing in again fetches a fresh session and CSRF token, so either rejection is recoverable. */ function needsReauthentication(error: unknown): boolean { - if (getStatusCode(error) === 401) return true; + const statusCode = getStatusCode(error); + // A server error leaves write outcomes uncertain, even when its body contains an auth code. + if (statusCode !== undefined && statusCode >= 500) return false; + if (statusCode === 401) return true; const code = getTimeTreeErrorCode(error); return code === TIMETREE_ERROR.SESSION_REJECTED || code === TIMETREE_ERROR.CSRF_REJECTED; } @@ -258,6 +262,33 @@ export class TimeTreeAPIClient { } } + /** Create a calendar without invitations. Rate limited; timeouts and server errors are not retried. */ + async createCalendar(name: string, purpose: string): Promise { + if (!name.trim() || name.trim().length > 20) { + throw new TimeTreeAPIError('Calendar name must contain 1 to 20 characters', 400); + } + await this.ensureAuthenticated(); + // Precondition check: writes require a CSRF token before sending the request. + this.authManager.getCsrfToken(); + try { + const response = await this.send(() => + this.authManager.getHttpClient().post<{ calendar: unknown }>( + `${TIMETREE_CONFIG.V2_BASE_URL}/calendars`, + { name: name.trim(), purpose }, undefined, true + ) + ); + return CalendarSchema.parse(response.calendar); + } catch (error) { + if (getStatusCode(error) === 403) { + throw new TimeTreeAPIError('CSRF token missing or invalid - re-authentication required', 403); + } + if (needsReauthentication(error)) { + throw new AuthenticationError('Session or CSRF token rejected after re-authentication', getStatusCode(error)); + } + throw error; + } + } + /** * Fetch every page of an event feed. TimeTree splits large responses into chunks: while * `chunk` is true, the returned `since` is the cursor for the next page. diff --git a/src/tools/calendar-tools.ts b/src/tools/calendar-tools.ts index 3be8a90..e926efd 100644 --- a/src/tools/calendar-tools.ts +++ b/src/tools/calendar-tools.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import type { TimeTreeAPIClient } from '../client/api.js'; +import { AuthenticationError } from '../client/auth.js'; import { logger } from '../utils/logger.js'; export const ListCalendarsInputSchema = z.object({}); @@ -80,3 +81,61 @@ export function createListCalendarsTool(apiClient: TimeTreeAPIClient) { }, }; } + +export const CreateCalendarInputSchema = z.object({ + name: z.string().trim().min(1).max(20), + purpose: z.enum(['family', 'private', 'lover', 'work', 'friend', 'work_schedule', + 'lesson', 'school_event', 'circle', 'hobby', 'other']), +}).strict(); + +export function createCreateCalendarTool(apiClient: TimeTreeAPIClient) { + return { + name: 'create_calendar', + description: 'Create a calendar owned by the authenticated user. Does not invite members or change existing calendars. Purpose is required. Requests are rate-limited; timeouts and server errors are not retried.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 20 }, + purpose: { type: 'string', enum: ['family', 'private', 'lover', 'work', 'friend', + 'work_schedule', 'lesson', 'school_event', 'circle', 'hobby', 'other'] }, + }, + required: ['name', 'purpose'], + additionalProperties: false, + }, + handler: async (args: unknown) => { + const parsed = CreateCalendarInputSchema.safeParse(args); + if (!parsed.success) { + const issues = parsed.error.issues.map((issue) => ({ + field: issue.path[0] === 'name' || issue.path[0] === 'purpose' ? issue.path[0] : 'input', + reason: issue.code === 'too_small' ? 'Must contain at least 1 character' + : issue.code === 'too_big' ? 'Must contain at most 20 characters' + : issue.code === 'invalid_enum_value' ? 'Choose a supported calendar purpose' + : issue.code === 'unrecognized_keys' ? 'Only name and purpose are allowed' + : 'Required field with a supported type and value', + })); + return { content: [{ type: 'text', text: JSON.stringify({ + error: 'Invalid calendar input', issues, + }) }], isError: true }; + } + // Response validation can fail after the calendar was created; keep it out of input errors. + try { + const calendar = await apiClient.createCalendar(parsed.data.name, parsed.data.purpose); + return { content: [{ type: 'text', text: JSON.stringify({ + id: String(calendar.id), name: calendar.name, alias_code: calendar.alias_code ?? null, + }) }] }; + } catch (error) { + const statusCode = error && typeof error === 'object' && 'statusCode' in error + ? error.statusCode : undefined; + if (error instanceof AuthenticationError || statusCode === 401 || statusCode === 403) { + return { content: [{ type: 'text', text: JSON.stringify({ + error: 'Authentication failed', + }) }], isError: true }; + } + logger.error('Tool: create_calendar failed', { error }); + return { content: [{ type: 'text', text: JSON.stringify({ + error: 'Calendar creation failed. Check existing calendars before retrying.', + }) }], isError: true }; + } + }, + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 8fb56c7..2358e91 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,7 +4,7 @@ */ import type { TimeTreeAPIClient } from '../client/api.js'; -import { createListCalendarsTool } from './calendar-tools.js'; +import { createCreateCalendarTool, createListCalendarsTool } from './calendar-tools.js'; import { createGetCalendarLabelsTool, createGetCalendarMembersTool, @@ -35,6 +35,7 @@ import { export function registerTools(apiClient: TimeTreeAPIClient) { return [ createListCalendarsTool(apiClient), + createCreateCalendarTool(apiClient), createGetEventsTool(apiClient), createGetUpdatedEventsTool(apiClient), createCreateEventTool(apiClient), diff --git a/tests/api-client.test.mjs b/tests/api-client.test.mjs index 36fd038..5e46cf5 100644 --- a/tests/api-client.test.mjs +++ b/tests/api-client.test.mjs @@ -21,13 +21,15 @@ function makeEvent(overrides = {}) { }; } -function makeClient(http) { +function makeClient(http, auth = {}) { return new TimeTreeAPIClient({ isAuthenticated: () => true, authenticate: async () => { throw new Error('authenticate should not be called in unit tests'); }, getHttpClient: () => http, + getCsrfToken: () => "test-token", + ...auth, }); } @@ -165,6 +167,25 @@ test('event comment methods use activity endpoints and filter comment activities assert.ok(calls.some((call) => call.url === 'https://timetreeapp.com/api/v1/calendar/123/event/evt/activity/comment')); }); +test('createCalendar uses v2 CSRF-protected creation without invitations, with rate limiting', async () => { + const calls = []; + const client = makeClient({post: async (...args) => { + calls.push(args); + return { calendar: {id: 456, name: 'Private', alias_code: 'new-alias'} }; + }}); + let throttled = 0; + const retry = client.rateLimiter.executeWithRetry.bind(client.rateLimiter); + client.rateLimiter.executeWithRetry = (...args) => { throttled++; return retry(...args); }; + assert.equal((await client.createCalendar(' Private ', 'lover')).id, 456); + assert.equal(throttled, 1); + assert.deepEqual(calls, [['https://timetreeapp.com/api/v2/calendars', + {name: 'Private', purpose: 'lover'}, undefined, true]]); + let count = 0; + const failing = makeClient({post: async () => {count++; throw new Error('timeout');}}); + await assert.rejects(failing.createCalendar('Private', 'private')); + assert.equal(count, 1); +}); + test('createEvent stores url in attachment and sends an empty checklist as null', async () => { const calls = []; const client = makeClient({ @@ -267,3 +288,64 @@ test('getLatestEventActivities queries the latest activity feed for calendars', assert.deepEqual(url.searchParams.getAll('calendar_ids[]'), ['123']); assert.deepEqual(events[0].activities[0].status, [1]); }); + +test('createCalendar retries a rejected 429 request but not timeouts or 5xx', async () => { + let count = 0; + const limited = makeClient({ post: async () => { + if (++count === 1) throw Object.assign(new Error('rate limited'), {statusCode: 429}); + return {calendar: {id: 456, name: 'Sample', alias_code: 'sample'}}; + }}); + await limited.createCalendar('Sample', 'work'); + assert.equal(count, 2); + for (const error of [new Error('timeout'), Object.assign(new Error('server failure'), {statusCode: 500})]) { + let requests = 0; + const client = makeClient({post: async () => {requests++; throw error;}}); + await assert.rejects(client.createCalendar('Sample', 'work')); + assert.equal(requests, 1); + } +}); + +test('createCalendar maps 403 without leaking the upstream error', async () => { + const client = makeClient({post: async () => {throw Object.assign(new Error('upstream detail'), {statusCode: 403});}}); + await assert.rejects(client.createCalendar('Sample', 'work'), error => + error.name === 'TimeTreeAPIError' && error.statusCode === 403 && !error.message.includes('upstream detail')); +}); + +test('createCalendar never retries 5xx carrying session or CSRF error codes', async () => { + for (const statusCode of [500, 502, 503, 504]) { + for (const code of [-493, -1]) { + let requests = 0; + let signIns = 0; + const error = Object.assign(new Error('synthetic server failure'), { + statusCode, response: JSON.stringify({error: {code}}), + }); + const client = makeClient({post: async () => { + requests++; + if (requests === 1) throw error; + return {calendar: {id: 456, name: 'Sample'}}; + }}, {authenticate: async () => {signIns++;}}); + + await assert.rejects(client.createCalendar('Sample', 'work'), thrown => thrown === error); + assert.equal(requests, 1, `HTTP ${statusCode}, code ${code} was retried`); + assert.equal(signIns, 0); + } + } +}); + +test('createCalendar still recovers rejected authentication once', async () => { + for (const [statusCode, code] of [[401, undefined], [400, -493], [422, -1]]) { + let requests = 0; + let signIns = 0; + const client = makeClient({post: async () => { + requests++; + if (requests === 1) throw Object.assign(new Error('synthetic auth rejection'), { + statusCode, response: JSON.stringify({error: {code}}), + }); + return {calendar: {id: 456, name: 'Sample'}}; + }}, {authenticate: async () => {signIns++;}}); + + assert.equal((await client.createCalendar('Sample', 'work')).id, 456); + assert.equal(requests, 2); + assert.equal(signIns, 1); + } +}); diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index 88da46a..df50701 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -40,6 +40,7 @@ test('registerTools exposes baseline plus Wave 1 tools', () => { assert.deepEqual(names, [ 'add_event_comment', + 'create_calendar', 'create_event', 'create_memo', 'delete_event', @@ -346,3 +347,89 @@ test('get_recent_activity names status codes, joins member names, and filters by ['memo-1', 'memo', true], ]); }); + +test('create_calendar requires explicit purpose and returns only documented fields', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const calls = []; + const tool = createCreateCalendarTool({createCalendar: async (...args) => { + calls.push(args); return {id: 123, name: 'Sample', alias_code: 'sample', extra: 'hidden'}; + }}); + assert.deepEqual(tool.inputSchema.required, ['name', 'purpose']); + assert.equal('default' in tool.inputSchema.properties.purpose, false); + assert.deepEqual(parseToolText(await tool.handler({name: 'Sample', purpose: 'work'})), + {id: '123', name: 'Sample', alias_code: 'sample'}); + for (const args of [{name: 'Sample'}, {name: 'x'.repeat(21), purpose: 'work'}, + {name: 'Sample', purpose: 'unsupported-input-value'}, {name: 'Sample', purpose: 'work', extra: 'hidden'}]) { + const result = await tool.handler(args); + assert.equal(result.isError, true); + assert.equal(parseToolText(result).error, 'Invalid calendar input'); + assert.ok(parseToolText(result).issues.length); + assert.equal(JSON.stringify(result).includes('unsupported-input-value'), false); + } + assert.equal(calls.length, 1); +}); + +test('create_calendar distinguishes auth and uncertain failures without forwarding details', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const {AuthenticationError} = await import('../.test-dist/client/auth.js'); + for (const error of [new AuthenticationError('upstream detail'), + Object.assign(new Error('upstream detail'), {statusCode: 401}), + Object.assign(new Error('upstream detail'), {statusCode: 403}), new Error('upstream detail')]) { + const tool = createCreateCalendarTool({createCalendar: async () => {throw error;}}); + const result = await tool.handler({name: 'Sample', purpose: 'work'}); + assert.equal(result.isError, true); + assert.equal(JSON.stringify(result).includes('upstream detail'), false); + assert.equal(parseToolText(result).error, error instanceof AuthenticationError || error.statusCode === 401 || error.statusCode === 403 + ? 'Authentication failed' : 'Calendar creation failed. Check existing calendars before retrying.'); + } +}); + +test('create_calendar treats response validation errors as uncertain creation outcomes', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const {TimeTreeAPIClient} = await import('../.test-dist/client/api.js'); + for (const calendar of [ + {id: 456, name: 'Sample', alias_code: null}, + {id: 456, name: null, alias_code: 'sample'}, + ]) { + let requests = 0; + const client = new TimeTreeAPIClient({ + isAuthenticated: () => true, + getCsrfToken: () => 'synthetic-token', + getHttpClient: () => ({post: async () => {requests++; return {calendar};}}), + }); + const result = await createCreateCalendarTool(client).handler({name: 'Sample', purpose: 'work'}); + + assert.equal(requests, 1); + assert.equal(result.isError, true); + assert.deepEqual(parseToolText(result), { + error: 'Calendar creation failed. Check existing calendars before retrying.', + }); + } +}); + +test('create_calendar reports persistent TimeTree auth rejections as authentication failures', async () => { + const {createCreateCalendarTool} = await import('../.test-dist/tools/calendar-tools.js'); + const {TimeTreeAPIClient} = await import('../.test-dist/client/api.js'); + for (const [statusCode, code] of [[401, undefined], [400, -493], [422, -1]]) { + let requests = 0; + let signIns = 0; + const client = new TimeTreeAPIClient({ + isAuthenticated: () => true, + authenticate: async () => {signIns++;}, + getCsrfToken: () => 'synthetic-token', + getHttpClient: () => ({post: async () => { + requests++; + throw Object.assign(new Error('upstream detail'), { + statusCode, response: JSON.stringify({error: {code, message: 'upstream detail'}}), + }); + }}), + }); + const result = await createCreateCalendarTool(client).handler({name: 'Sample', purpose: 'work'}); + + assert.equal(requests, 2); + assert.equal(signIns, 1); + assert.equal(result.isError, true); + assert.deepEqual(parseToolText(result), {error: 'Authentication failed'}); + assert.equal(JSON.stringify(result).includes('upstream detail'), false); + } +});