Skip to content
Open
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
6 changes: 4 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions run-mcp.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
cd /home/ll/Projects/basecamp-mcp-server
exec node dist/bin/basecamp-mcp.js "$@"
39 changes: 39 additions & 0 deletions scripts/refresh-token.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/bash
# Refresh Basecamp OAuth token
# Run via cron: 0 9 * * 0 ~/Projects/basecamp-mcp-server/scripts/refresh-token.sh

set -e

cd ~/Projects/basecamp-mcp-server
source .env

TOKEN_FILE="oauth_tokens.json"
REFRESH_TOKEN=$(jq -r '.basecamp.refreshToken' "$TOKEN_FILE")

if [ -z "$REFRESH_TOKEN" ] || [ "$REFRESH_TOKEN" = "null" ]; then
echo "❌ No refresh token found"
exit 1
fi

# Request new access token
RESPONSE=$(curl -s -X POST "https://launchpad.37signals.com/authorization/token" \
-H "Content-Type: application/json" \
-d "{\"type\":\"refresh\",\"refresh_token\":\"$REFRESH_TOKEN\",\"client_id\":\"$BASECAMP_CLIENT_ID\",\"client_secret\":\"$BASECAMP_CLIENT_SECRET\",\"redirect_uri\":\"$BASECAMP_REDIRECT_URI\"}")

NEW_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
EXPIRES_IN=$(echo "$RESPONSE" | jq -r '.expires_in')

if [ -z "$NEW_TOKEN" ] || [ "$NEW_TOKEN" = "null" ]; then
echo "❌ Failed to refresh token: $RESPONSE"
exit 1
fi

# Update token file
EXPIRES_AT=$(date -d "+${EXPIRES_IN} seconds" -Iseconds)
UPDATED_AT=$(date -Iseconds)

jq --arg token "$NEW_TOKEN" --arg expires "$EXPIRES_AT" --arg updated "$UPDATED_AT" \
'.basecamp.accessToken = $token | .basecamp.expiresAt = $expires | .basecamp.updatedAt = $updated' \
"$TOKEN_FILE" > "${TOKEN_FILE}.tmp" && mv "${TOKEN_FILE}.tmp" "$TOKEN_FILE"

echo "✅ Token refreshed. Expires: $EXPIRES_AT"
191 changes: 191 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,93 @@ class BasecampMCPServer {
required: ['project_id', 'question_id'],
},
},

// Schedule tools
{
name: 'get_schedule',
description: 'Get the schedule for a project',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'string', description: 'The project ID' },
},
required: ['project_id'],
},
},
{
name: 'get_schedule_entries',
description: 'Get schedule entries (calendar events) for a project',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'string', description: 'The project ID' },
schedule_id: { type: 'string', description: 'The schedule ID' },
status: { type: 'string', description: 'Filter by status: active, archived, or trashed (default: active)' },
},
required: ['project_id', 'schedule_id'],
},
},
{
name: 'get_schedule_entry',
description: 'Get a specific schedule entry (calendar event)',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'string', description: 'The project ID' },
entry_id: { type: 'string', description: 'The schedule entry ID' },
},
required: ['project_id', 'entry_id'],
},
},
{
name: 'create_schedule_entry',
description: 'Create a new schedule entry (calendar event)',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'string', description: 'The project ID' },
schedule_id: { type: 'string', description: 'The schedule ID' },
summary: { type: 'string', description: 'Event title/summary' },
starts_at: { type: 'string', description: 'Start date-time in ISO 8601 format (e.g., 2026-01-15T10:00:00Z)' },
ends_at: { type: 'string', description: 'End date-time in ISO 8601 format (e.g., 2026-01-15T11:00:00Z)' },
description: { type: 'string', description: 'Optional event description' },
participant_ids: { type: 'array', items: { type: 'string' }, description: 'Array of person IDs to invite' },
all_day: { type: 'boolean', description: 'Whether this is an all-day event (default: false)' },
notify: { type: 'boolean', description: 'Whether to notify participants (default: false)' },
},
required: ['project_id', 'schedule_id', 'summary', 'starts_at', 'ends_at'],
},
},
{
name: 'update_schedule_entry',
description: 'Update an existing schedule entry (calendar event)',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'string', description: 'The project ID' },
entry_id: { type: 'string', description: 'The schedule entry ID' },
summary: { type: 'string', description: 'New event title/summary' },
starts_at: { type: 'string', description: 'New start date-time in ISO 8601 format' },
ends_at: { type: 'string', description: 'New end date-time in ISO 8601 format' },
description: { type: 'string', description: 'New event description' },
participant_ids: { type: 'array', items: { type: 'string' }, description: 'New array of person IDs' },
all_day: { type: 'boolean', description: 'Whether this is an all-day event' },
},
required: ['project_id', 'entry_id'],
},
},
{
name: 'delete_schedule_entry',
description: 'Delete a schedule entry (calendar event)',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'string', description: 'The project ID' },
entry_id: { type: 'string', description: 'The schedule entry ID' },
},
required: ['project_id', 'entry_id'],
},
},
],
};
});
Expand Down Expand Up @@ -828,6 +915,110 @@ class BasecampMCPServer {
};
}

case 'get_schedule': {
const schedule = await client.getSchedule(typedArgs.project_id);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
schedule
}, null, 2)
}]
};
}

case 'get_schedule_entries': {
const entries = await client.getScheduleEntries(
typedArgs.project_id,
typedArgs.schedule_id,
typedArgs.status
);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
schedule_entries: entries,
count: entries.length
}, null, 2)
}]
};
}

case 'get_schedule_entry': {
const entry = await client.getScheduleEntry(typedArgs.project_id, typedArgs.entry_id);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
schedule_entry: entry
}, null, 2)
}]
};
}

case 'create_schedule_entry': {
const entry = await client.createScheduleEntry(
typedArgs.project_id,
typedArgs.schedule_id,
typedArgs.summary,
typedArgs.starts_at,
typedArgs.ends_at,
typedArgs.description,
typedArgs.participant_ids,
typedArgs.all_day || false,
typedArgs.notify || false
);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
schedule_entry: entry,
message: `Schedule entry '${typedArgs.summary}' created successfully`
}, null, 2)
}]
};
}

case 'update_schedule_entry': {
const entry = await client.updateScheduleEntry(
typedArgs.project_id,
typedArgs.entry_id,
typedArgs.summary,
typedArgs.starts_at,
typedArgs.ends_at,
typedArgs.description,
typedArgs.participant_ids,
typedArgs.all_day
);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
schedule_entry: entry,
message: 'Schedule entry updated successfully'
}, null, 2)
}]
};
}

case 'delete_schedule_entry': {
await client.deleteScheduleEntry(typedArgs.project_id, typedArgs.entry_id);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
message: 'Schedule entry deleted successfully'
}, null, 2)
}]
};
}

default:
throw new McpError(
ErrorCode.MethodNotFound,
Expand Down
86 changes: 86 additions & 0 deletions src/lib/basecamp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,4 +434,90 @@ export class BasecampClient {
const response = await this.client.get(`/buckets/${projectId}/recordings/${recordingId}/events.json`);
return response.data;
}

// Schedule methods
async getSchedule(projectId: string): Promise<any> {
const project = await this.getProject(projectId);
const schedule = project.dock.find(item => item.name === 'schedule');

if (!schedule) {
throw new Error(`No schedule found for project ${projectId}`);
}

return schedule;
}

async getScheduleEntries(projectId: string, scheduleId: string, status = 'active'): Promise<any[]> {
const params: any = {};
if (status) params.status = status;

const response = await this.client.get(
`/buckets/${projectId}/schedules/${scheduleId}/entries.json`,
{ params }
);
return response.data;
}

async getScheduleEntry(projectId: string, entryId: string): Promise<any> {
const response = await this.client.get(`/buckets/${projectId}/schedule_entries/${entryId}.json`);
return response.data;
}

async createScheduleEntry(
projectId: string,
scheduleId: string,
summary: string,
startsAt: string,
endsAt: string,
description?: string,
participantIds?: string[],
allDay = false,
notify = false
): Promise<any> {
const data: any = {
summary,
starts_at: startsAt,
ends_at: endsAt,
all_day: allDay,
};

if (description) data.description = description;
if (participantIds && participantIds.length > 0) data.participant_ids = participantIds;
if (notify) data.notify = notify;

const response = await this.client.post(
`/buckets/${projectId}/schedules/${scheduleId}/entries.json`,
data
);
return response.data;
}

async updateScheduleEntry(
projectId: string,
entryId: string,
summary?: string,
startsAt?: string,
endsAt?: string,
description?: string,
participantIds?: string[],
allDay?: boolean
): Promise<any> {
const data: any = {};
if (summary) data.summary = summary;
if (startsAt) data.starts_at = startsAt;
if (endsAt) data.ends_at = endsAt;
if (description !== undefined) data.description = description;
if (participantIds) data.participant_ids = participantIds;
if (allDay !== undefined) data.all_day = allDay;

const response = await this.client.put(
`/buckets/${projectId}/schedule_entries/${entryId}.json`,
data
);
return response.data;
}

async deleteScheduleEntry(projectId: string, entryId: string): Promise<void> {
await this.client.put(`/buckets/${projectId}/recordings/${entryId}/status/trashed.json`);
}
}
Loading