@access-mcp/events
Version:
ACCESS-CI Events MCP Server - Get information about workshops, webinars, and training events
585 lines (545 loc) • 18.2 kB
text/typescript
import { BaseAccessServer, handleApiError } from "@access-mcp/shared";
import axios, { AxiosInstance } from "axios";
export class EventsServer extends BaseAccessServer {
private _eventsHttpClient?: AxiosInstance;
constructor() {
super("access-mcp-events", "0.2.0", "https://support.access-ci.org");
}
protected get httpClient(): AxiosInstance {
if (!this._eventsHttpClient) {
const headers: any = {
"User-Agent": `${this.serverName}/${this.version}`,
};
// Add authentication if API key is provided
const apiKey = process.env.ACCESS_CI_API_KEY;
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
this._eventsHttpClient = axios.create({
baseURL: this.baseURL,
timeout: 10000, // 10 seconds for events API (can be slower)
headers,
validateStatus: () => true, // Don't throw on HTTP errors
});
}
return this._eventsHttpClient;
}
protected getTools() {
return [
{
name: "get_events",
description:
"Get ACCESS-CI events with comprehensive filtering capabilities. Returns events in UTC timezone with enhanced metadata.",
inputSchema: {
type: "object",
properties: {
// Relative date filtering
beginning_date_relative: {
type: "string",
description:
"Start date using relative values. Calculated in UTC by default, or use 'timezone' parameter for local calculations.",
enum: [
"today",
"+1week",
"+2week",
"+1month",
"+2month",
"+1year",
"-1week",
"-1month",
"-1year",
],
},
end_date_relative: {
type: "string",
description:
"End date using relative values. Calculated in UTC by default, or use 'timezone' parameter for local calculations.",
enum: [
"today",
"+1week",
"+2week",
"+1month",
"+2month",
"+1year",
"-1week",
"-1month",
"-1year",
],
},
// Absolute date filtering
beginning_date: {
type: "string",
description:
"Start date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format. Always interpreted as provided (no timezone conversion).",
},
end_date: {
type: "string",
description:
"End date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format. Always interpreted as provided (no timezone conversion).",
},
// Timezone parameter for relative date calculations
timezone: {
type: "string",
description:
"Timezone for relative date calculations (default: UTC). Common values: UTC, America/New_York (Eastern), America/Chicago (Central), America/Denver (Mountain), America/Los_Angeles (Pacific), Europe/London (British), Europe/Berlin (CET). Only affects relative dates, not absolute dates. Invalid timezones default to UTC.",
default: "UTC",
},
// Faceted search filters
event_type: {
type: "string",
description: "Filter by event type (workshop, webinar, etc.)",
},
event_affiliation: {
type: "string",
description:
"Filter by organizational affiliation (Community, ACCESS, etc.)",
},
skill_level: {
type: "string",
description:
"Filter by required skill level (beginner, intermediate, advanced)",
enum: ["beginner", "intermediate", "advanced"],
},
event_tags: {
type: "string",
description:
"Filter by event tags (python, big-data, machine-learning, etc.)",
},
limit: {
type: "number",
description: "Maximum number of events to return (default: 100)",
minimum: 1,
maximum: 1000,
},
},
required: [],
},
},
{
name: "get_upcoming_events",
description: "Get upcoming ACCESS-CI events (from today onward in UTC). Convenient shortcut for get_events with beginning_date_relative=today.",
inputSchema: {
type: "object",
properties: {
limit: {
type: "number",
description: "Maximum number of events to return (default: 50)",
minimum: 1,
maximum: 100,
},
event_type: {
type: "string",
description: "Filter by event type (workshop, webinar, Office Hours, Training, etc.)",
},
timezone: {
type: "string",
description: "Timezone for 'today' calculation (default: UTC). Use user's local timezone for better relevance.",
default: "UTC",
},
},
required: [],
},
},
{
name: "search_events",
description: "Search events using API's native full-text search. Searches across titles, descriptions, speakers, tags, location, and event type. Much more powerful than tag filtering.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (case-insensitive). Use spaces for multiple words (e.g., 'machine learning', 'office hours'). Searches across all event content including descriptions.",
},
beginning_date_relative: {
type: "string",
description: "Start date using relative values (default: today). Use '-1month' or '-1year' to search past events, or omit for all-time search.",
default: "today",
},
timezone: {
type: "string",
description: "Timezone for relative date calculation (default: UTC)",
default: "UTC",
},
limit: {
type: "number",
description: "Maximum number of events to return (default: 25)",
minimum: 1,
maximum: 100,
},
},
required: ["query"],
},
},
{
name: "get_events_by_tag",
description: "Get events filtered by specific tags. Useful for finding events on topics like 'python', 'ai', 'machine-learning', 'gpu', etc.",
inputSchema: {
type: "object",
properties: {
tag: {
type: "string",
description:
"Event tag to filter by. Common tags: python, ai, machine-learning, gpu, deep-learning, neural-networks, big-data, hpc, jetstream, neocortex",
},
time_range: {
type: "string",
description: "Time range for events (upcoming=today onward, this_week=next 7 days, this_month=next 30 days, all=no date filter)",
enum: ["upcoming", "this_week", "this_month", "all"],
default: "upcoming",
},
timezone: {
type: "string",
description: "Timezone for time_range calculations (default: UTC)",
default: "UTC",
},
limit: {
type: "number",
description: "Maximum number of events to return (default: 25)",
minimum: 1,
maximum: 100,
},
},
required: ["tag"],
},
},
];
}
protected getResources() {
return [
{
uri: "accessci://events",
name: "ACCESS-CI Events",
description:
"Comprehensive events data including workshops, webinars, and training",
mimeType: "application/json",
},
{
uri: "accessci://events/upcoming",
name: "Upcoming Events",
description: "Events scheduled for today and beyond",
mimeType: "application/json",
},
{
uri: "accessci://events/workshops",
name: "Workshops",
description: "Workshop events only",
mimeType: "application/json",
},
{
uri: "accessci://events/webinars",
name: "Webinars",
description: "Webinar events only",
mimeType: "application/json",
},
];
}
async handleToolCall(request: any) {
const { name, arguments: args = {} } = request.params;
try {
switch (name) {
case "get_events":
return await this.getEvents(args);
case "get_upcoming_events":
return await this.getUpcomingEvents(args);
case "search_events":
return await this.searchEvents(args);
case "get_events_by_tag":
return await this.getEventsByTag(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${handleApiError(error)}`,
},
],
};
}
}
async handleResourceRead(request: any) {
const { uri } = request.params;
switch (uri) {
case "accessci://events":
const allEvents = await this.getEvents({});
return {
contents: [
{
uri,
mimeType: "application/json",
text: allEvents.content[0].text,
},
],
};
case "accessci://events/upcoming":
const upcomingEvents = await this.getUpcomingEvents({});
return {
contents: [
{
uri,
mimeType: "application/json",
text: upcomingEvents.content[0].text,
},
],
};
case "accessci://events/workshops":
const workshops = await this.getEvents({ event_type: "workshop" });
return {
contents: [
{
uri,
mimeType: "application/json",
text: workshops.content[0].text,
},
],
};
case "accessci://events/webinars":
const webinars = await this.getEvents({ event_type: "webinar" });
return {
contents: [
{
uri,
mimeType: "application/json",
text: webinars.content[0].text,
},
],
};
default:
throw new Error(`Unknown resource: ${uri}`);
}
}
private buildEventsUrl(params: any): string {
const url = new URL("/api/2.1/events", this.baseURL);
// Add full-text search parameter (API native search)
if (params.search_api_fulltext) {
url.searchParams.set("search_api_fulltext", params.search_api_fulltext);
}
// Add date filtering parameters
if (params.beginning_date_relative) {
url.searchParams.set(
"beginning_date_relative",
params.beginning_date_relative,
);
}
if (params.end_date_relative) {
url.searchParams.set("end_date_relative", params.end_date_relative);
}
if (params.beginning_date) {
url.searchParams.set("beginning_date", params.beginning_date);
}
if (params.end_date) {
url.searchParams.set("end_date", params.end_date);
}
// Add timezone parameter for relative date calculations
if (params.timezone) {
url.searchParams.set("timezone", params.timezone);
}
// Add faceted search filters
let filterIndex = 0;
if (params.event_type) {
url.searchParams.set(
`f[${filterIndex}]`,
`custom_event_type:${params.event_type}`,
);
filterIndex++;
}
if (params.event_affiliation) {
url.searchParams.set(
`f[${filterIndex}]`,
`custom_event_affiliation:${params.event_affiliation}`,
);
filterIndex++;
}
if (params.skill_level) {
url.searchParams.set(
`f[${filterIndex}]`,
`skill_level:${params.skill_level}`,
);
filterIndex++;
}
if (params.event_tags) {
url.searchParams.set(
`f[${filterIndex}]`,
`custom_event_tags:${params.event_tags}`,
);
filterIndex++;
}
return url.toString();
}
private async getEvents(params: any) {
const url = this.buildEventsUrl(params);
const response = await this.httpClient.get(url);
if (response.status !== 200) {
throw new Error(
`Events API returned ${response.status}: ${response.statusText}`,
);
}
let events = response.data || [];
// Apply limit if specified
if (params.limit && events.length > params.limit) {
events = events.slice(0, params.limit);
}
// Enhance events with additional metadata
const enhancedEvents = events.map((event: any) => ({
...event,
// Parse dates for better handling
start_date: new Date(event.date),
end_date: event.date_1 ? new Date(event.date_1) : null,
// Split tags into array
tags: event.custom_event_tags
? event.custom_event_tags.split(",").map((tag: string) => tag.trim())
: [],
// Calculate duration if both dates present
duration_hours: event.date_1
? Math.round(
(new Date(event.date_1).getTime() -
new Date(event.date).getTime()) /
(1000 * 60 * 60),
)
: null,
// Relative timing
starts_in_hours: Math.max(
0,
Math.round(
(new Date(event.date).getTime() - Date.now()) / (1000 * 60 * 60),
),
),
}));
const summary = {
total_events: enhancedEvents.length,
upcoming_events: enhancedEvents.filter((e: any) => e.starts_in_hours >= 0)
.length,
events_this_week: enhancedEvents.filter(
(e: any) => e.starts_in_hours <= 168 && e.starts_in_hours >= 0,
).length,
api_info: {
endpoint_version: "2.1",
timezone_handling: "All timestamps in UTC (Z suffix). Relative dates calculated using timezone parameter (default: UTC).",
timezone_used: params.timezone || "UTC",
},
event_types: [
...new Set(
enhancedEvents.map((e: any) => e.event_type).filter(Boolean),
),
],
affiliations: [
...new Set(
enhancedEvents.map((e: any) => e.event_affiliation).filter(Boolean),
),
],
skill_levels: [
...new Set(
enhancedEvents.map((e: any) => e.skill_level).filter(Boolean),
),
],
popular_tags: this.getPopularTags(enhancedEvents),
events: enhancedEvents,
};
return {
content: [
{
type: "text",
text: JSON.stringify(summary, null, 2),
},
],
};
}
private async getUpcomingEvents(params: any) {
const upcomingParams = {
...params,
beginning_date_relative: "today",
limit: params.limit || 50,
// Pass through timezone if provided
...(params.timezone && { timezone: params.timezone }),
};
return this.getEvents(upcomingParams);
}
private async searchEvents(params: any) {
// Use API's native full-text search instead of client-side filtering
const searchParams = {
search_api_fulltext: params.query,
beginning_date_relative: params.beginning_date_relative || "today",
limit: params.limit || 25,
// Pass through timezone if provided
...(params.timezone && { timezone: params.timezone }),
};
// Use the API's native search capabilities
const eventsResponse = await this.getEvents(searchParams);
const eventsData = JSON.parse(eventsResponse.content[0].text);
// API returns already filtered results, no need for client-side filtering
const summary = {
search_query: params.query,
total_matches: eventsData.total_events,
search_method: "API native full-text search",
search_scope: "titles, descriptions, speakers, tags, location, event type",
events: eventsData.events,
};
return {
content: [
{
type: "text",
text: JSON.stringify(summary, null, 2),
},
],
};
}
private async getEventsByTag(params: any) {
const { tag, time_range = "upcoming", limit = 25, timezone } = params;
let dateParams: any = {};
switch (time_range) {
case "upcoming":
dateParams.beginning_date_relative = "today";
break;
case "this_week":
dateParams.beginning_date_relative = "today";
dateParams.end_date_relative = "+1week";
break;
case "this_month":
dateParams.beginning_date_relative = "today";
dateParams.end_date_relative = "+1month";
break;
case "all":
// No date restrictions
break;
}
const taggedParams = {
...dateParams,
event_tags: tag,
limit,
// Pass through timezone if provided
...(timezone && { timezone }),
};
const eventsResponse = await this.getEvents(taggedParams);
const eventsData = JSON.parse(eventsResponse.content[0].text);
const summary = {
tag: tag,
time_range: time_range,
total_events: eventsData.events.length,
events: eventsData.events,
};
return {
content: [
{
type: "text",
text: JSON.stringify(summary, null, 2),
},
],
};
}
private getPopularTags(events: any[]): string[] {
const tagCounts: { [key: string]: number } = {};
events.forEach((event) => {
if (event.tags) {
event.tags.forEach((tag: string) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
}
});
return Object.entries(tagCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([tag]) => tag);
}
}