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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 16 additions & 2 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface AuthResult {
* Returns the authentication token and user info
*/
export async function login(email: string, password: string): Promise<AuthResult> {
console.error(`[auth] Attempting login for ${email}...`);

const response = await fetch(`${BASE_URL}/api/sessions`, {
method: "POST",
headers: {
Expand All @@ -41,15 +43,27 @@ export async function login(email: string, password: string): Promise<AuthResult
body: JSON.stringify({ email, password }),
});

console.error(`[auth] Login response status: ${response.status}`);

if (!response.ok) {
let errorBody = "";
try {
errorBody = await response.text();
console.error(`[auth] Login error response: ${errorBody}`);
} catch {
// ignore
}

if (response.status === 401) {
throw new Error("Invalid email or password");
throw new Error(`Invalid email or password. Please check your SKYLIGHT_EMAIL and SKYLIGHT_PASSWORD environment variables.`);
}
throw new Error(`Login failed: HTTP ${response.status}`);
throw new Error(`Login failed: HTTP ${response.status}${errorBody ? ` - ${errorBody}` : ""}`);
}

const data = (await response.json()) as LoginResponse;

console.error(`[auth] Login successful, token prefix: ${data.data.attributes.token.substring(0, 10)}...`);

return {
userId: data.data.id,
email: data.data.attributes.email,
Expand Down
68 changes: 47 additions & 21 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,47 +27,51 @@ export interface RequestOptions {
export class SkylightClient {
private config: Config;
private resolvedToken: string | null = null;
private loginPromise: Promise<string> | null = null;
private resolvedUserId: string | null = null;
private loginPromise: Promise<{ token: string; userId: string }> | null = null;
private subscriptionStatus: SubscriptionStatus = null;

constructor(config?: Config) {
this.config = config ?? getConfig();
}

/**
* Get the authentication token
* Get the authentication credentials
* If using email/password auth, will login first
*/
private async getToken(): Promise<string> {
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<string> {
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");
Expand All @@ -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<string> {
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
Expand Down Expand Up @@ -119,12 +125,21 @@ export class SkylightClient {
/**
* Handle API response errors
*/
private async handleResponseError(response: Response): Promise<never> {
private async handleResponseError(response: Response, url: string): Promise<never> {
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();
}

Expand Down Expand Up @@ -154,13 +169,15 @@ export class SkylightClient {
/**
* Make an authenticated request to the Skylight API
*/
async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
async request<T>(endpoint: string, options: RequestOptions = {}, isRetry = false): Promise<T> {
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<string, string> = {
Authorization: await this.getAuthHeader(),
Accept: "application/json",
Expand All @@ -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<T>(endpoint, options, true);
}
await this.handleResponseError(response, url);
}

// Handle 304 Not Modified
Expand Down Expand Up @@ -234,7 +260,7 @@ export class SkylightClient {
* Initialize the client (triggers login if using email/password auth)
*/
async initialize(): Promise<void> {
await this.getToken();
await this.getCredentials();
}
}

Expand Down
16 changes: 15 additions & 1 deletion src/api/endpoints/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CalendarEventResource[]> {
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<CalendarEventsResponse>(
"/api/frames/{frameId}/calendar_events",
{
date_min: options.dateMin,
date_max: options.dateMax,
date_max: adjustedDateMax,
timezone: options.timezone ?? client.timezone,
include: options.include,
}
Expand Down