diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..984cefe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +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). + +## [1.1.7] - 2025-12-30 + +### Fixed + +- **Authentication**: Fixed email/password authentication to use correct `Basic base64(userId:token)` format instead of `Bearer token`. The Skylight API requires the user ID and token to be combined and base64-encoded for Basic auth. +- **Calendar Events**: Fixed `get_calendar_events` returning no events when querying a single day. The API treats `date_max` as exclusive, so we now add 1 day to ensure events on the end date are included. + +### Changed + +- Added debug logging for authentication flow to help troubleshoot login issues +- Added automatic retry on 401 errors for email/password auth (attempts re-login once before failing) + +## [1.1.6] - 2025-12-29 + +- Initial public release diff --git a/package.json b/package.json index 2fb7efd..eb5f259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eaglebyte/skylight-mcp", - "version": "1.1.6", + "version": "1.1.7", "description": "MCP server for Skylight Calendar API - enables agentic interactions for calendar, chores, lists, and family management", "type": "module", "main": "dist/index.js", diff --git a/src/api/auth.ts b/src/api/auth.ts index 6b5c533..830ee6d 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -32,6 +32,8 @@ export interface AuthResult { * Returns the authentication token and user info */ export async function login(email: string, password: string): Promise { + console.error(`[auth] Attempting login for ${email}...`); + const response = await fetch(`${BASE_URL}/api/sessions`, { method: "POST", headers: { @@ -41,15 +43,27 @@ export async function login(email: string, password: string): Promise | null = null; + private resolvedUserId: string | null = null; + private loginPromise: Promise<{ token: string; userId: string }> | null = null; private subscriptionStatus: SubscriptionStatus = null; constructor(config?: Config) { @@ -35,39 +36,42 @@ export class SkylightClient { } /** - * Get the authentication token + * Get the authentication credentials * If using email/password auth, will login first */ - private async getToken(): Promise { + private async getCredentials(): Promise<{ token: string; userId: string | null }> { // If we already have a resolved token, use it if (this.resolvedToken) { - return this.resolvedToken; + return { token: this.resolvedToken, userId: this.resolvedUserId }; } // If using token-based auth, use the configured token if (!usesEmailAuth(this.config)) { - return this.config.token!; + return { token: this.config.token!, userId: null }; } // If already logging in, wait for that to complete if (this.loginPromise) { - return this.loginPromise; + const result = await this.loginPromise; + return { token: result.token, userId: result.userId }; } // Login with email/password this.loginPromise = this.performLogin(); try { - this.resolvedToken = await this.loginPromise; - return this.resolvedToken; + const result = await this.loginPromise; + this.resolvedToken = result.token; + this.resolvedUserId = result.userId; + return result; } finally { this.loginPromise = null; } } /** - * Perform login and return token + * Perform login and return token and userId */ - private async performLogin(): Promise { + private async performLogin(): Promise<{ token: string; userId: string }> { const { email, password } = this.config; if (!email || !password) { throw new AuthenticationError("Email and password are required for login"); @@ -77,19 +81,21 @@ export class SkylightClient { const result = await login(email, password); this.subscriptionStatus = result.subscriptionStatus as SubscriptionStatus; console.error(`Logged in as ${result.email} (${result.subscriptionStatus})`); - return result.token; + return { token: result.token, userId: result.userId }; } /** * Build the Authorization header + * For email/password auth: Basic base64(userId:token) + * For manual token auth: Bearer or Basic based on config */ private async getAuthHeader(): Promise { - const token = await this.getToken(); + const { token, userId } = await this.getCredentials(); - // If using email/password auth, the token format is like "atu_xxx" - // which should be used as a Bearer token - if (usesEmailAuth(this.config)) { - return `Bearer ${token}`; + // If using email/password auth, use Basic auth with userId:token + if (usesEmailAuth(this.config) && userId) { + const credentials = Buffer.from(`${userId}:${token}`).toString("base64"); + return `Basic ${credentials}`; } // For manual token config, respect the authType setting @@ -119,12 +125,21 @@ export class SkylightClient { /** * Handle API response errors */ - private async handleResponseError(response: Response): Promise { + private async handleResponseError(response: Response, url: string): Promise { const status = response.status; if (status === 401) { - // Clear cached token on auth failure + // Clear cached credentials on auth failure this.resolvedToken = null; + this.resolvedUserId = null; + console.error(`[client] 401 Unauthorized for ${url}`); + + if (usesEmailAuth(this.config)) { + throw new AuthenticationError( + "API request returned 401. This may indicate your frame ID is incorrect or doesn't belong to this account. " + + "Please verify your SKYLIGHT_FRAME_ID environment variable." + ); + } throw new AuthenticationError(); } @@ -154,13 +169,15 @@ export class SkylightClient { /** * Make an authenticated request to the Skylight API */ - async request(endpoint: string, options: RequestOptions = {}): Promise { + async request(endpoint: string, options: RequestOptions = {}, isRetry = false): Promise { const { method = "GET", params, body } = options; // Replace {frameId} placeholder with actual frame ID const resolvedEndpoint = endpoint.replace("{frameId}", this.config.frameId); const url = this.buildUrl(resolvedEndpoint, params); + console.error(`[client] ${method} ${url}`); + const headers: Record = { Authorization: await this.getAuthHeader(), Accept: "application/json", @@ -176,8 +193,17 @@ export class SkylightClient { body: body ? JSON.stringify(body) : undefined, }); + console.error(`[client] Response: ${response.status}`); + if (!response.ok) { - await this.handleResponseError(response); + // For email/password auth, try re-login once on 401 + if (response.status === 401 && usesEmailAuth(this.config) && !isRetry) { + console.error("[client] Got 401, attempting re-login..."); + this.resolvedToken = null; + this.resolvedUserId = null; + return this.request(endpoint, options, true); + } + await this.handleResponseError(response, url); } // Handle 304 Not Modified @@ -234,7 +260,7 @@ export class SkylightClient { * Initialize the client (triggers login if using email/password auth) */ async initialize(): Promise { - await this.getToken(); + await this.getCredentials(); } } diff --git a/src/api/endpoints/calendar.ts b/src/api/endpoints/calendar.ts index a00dcc9..cfcefe6 100644 --- a/src/api/endpoints/calendar.ts +++ b/src/api/endpoints/calendar.ts @@ -16,18 +16,32 @@ export interface GetCalendarEventsOptions { include?: string; } +/** + * Add days to a date string in YYYY-MM-DD format + */ +function addDays(dateStr: string, days: number): string { + const date = new Date(dateStr + "T00:00:00"); + date.setDate(date.getDate() + days); + return date.toISOString().split("T")[0]; +} + /** * Get calendar events for a date range + * Note: The API treats date_max as exclusive, so we add 1 day to include events on the end date */ export async function getCalendarEvents( options: GetCalendarEventsOptions ): Promise { const client = getClient(); + + // API treats date_max as exclusive, so add 1 day to include events on the end date + const adjustedDateMax = addDays(options.dateMax, 1); + const response = await client.get( "/api/frames/{frameId}/calendar_events", { date_min: options.dateMin, - date_max: options.dateMax, + date_max: adjustedDateMax, timezone: options.timezone ?? client.timezone, include: options.include, }