@access-mcp/system-status
Version:
MCP server for ACCESS-CI System Status and Outages API
599 lines (598 loc) • 26.7 kB
JavaScript
import { BaseAccessServer, handleApiError, resolveResourceId, } from "@access-mcp/shared";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { version } = require("../package.json");
export class SystemStatusServer extends BaseAccessServer {
constructor() {
super("access-mcp-system-status", version, "https://operations-api.access-ci.org");
}
/**
* Search for resources by name to resolve human-readable names to full IDs.
* Used by resolveResourceId callback.
*/
async searchResourcesByName(query) {
try {
const response = await this.httpClient.get("/wh2/cider/v1/access-active-groups/type/resource-catalog.access-ci.org/");
const groups = response.data.results?.active_groups || [];
const queryLower = query.toLowerCase();
return groups
.filter((g) => g.group_descriptive_name?.toLowerCase().includes(queryLower))
.map((g) => ({
id: g.info_groupid || "",
name: g.group_descriptive_name || "",
}));
}
catch {
return [];
}
}
getTools() {
return [
{
name: "get_infrastructure_news",
description: "Get ACCESS-CI infrastructure status (outages, maintenance, incidents). Returns {total, items}.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Filter by resource name (e.g., 'delta', 'bridges2')",
},
time: {
type: "string",
enum: ["current", "scheduled", "past", "all"],
description: "Time filter. Values: 'current' for active outages, 'scheduled' for future/planned, 'past' for historical, 'all' for everything",
default: "current",
},
ids: {
type: "array",
items: { type: "string" },
description: "Check status for specific resources. Accepts names (e.g., 'Anvil', 'Delta') or full IDs (e.g., 'anvil.purdue.access-ci.org')",
},
limit: {
type: "number",
description: "Max results (default: 50)",
default: 50,
},
use_group_api: {
type: "boolean",
description: "Use group API for status (with ids only)",
default: false,
},
},
},
},
];
}
getResources() {
return [
{
uri: "accessci://system-status",
name: "ACCESS-CI System Status",
description: "Real-time status of ACCESS-CI infrastructure, outages, and maintenance",
mimeType: "application/json",
},
{
uri: "accessci://outages/current",
name: "Current Outages",
description: "Currently active outages and system issues",
mimeType: "application/json",
},
{
uri: "accessci://outages/scheduled",
name: "Scheduled Maintenance",
description: "Upcoming scheduled maintenance and planned outages",
mimeType: "application/json",
},
{
uri: "accessci://outages/past",
name: "Past Outages",
description: "Historical outages and past incidents",
mimeType: "application/json",
},
];
}
async handleToolCall(request) {
const { name, arguments: args = {} } = request.params;
const typedArgs = args;
try {
switch (name) {
case "get_infrastructure_news":
return await this.getInfrastructureNewsRouter({
resource: typedArgs.query,
time: typedArgs.time,
resource_ids: typedArgs.ids,
limit: typedArgs.limit,
use_group_api: typedArgs.use_group_api,
});
default:
return this.errorResponse(`Unknown tool: ${name}`);
}
}
catch (error) {
return this.errorResponse(handleApiError(error));
}
}
/**
* Router for consolidated get_infrastructure_news tool
* Routes to appropriate handler based on parameters
*/
async getInfrastructureNewsRouter(args) {
const { resource, time = "current", resource_ids, limit, use_group_api = false } = args;
// Check resource status (returns operational/affected) - only if IDs provided
if (resource_ids && Array.isArray(resource_ids) && resource_ids.length > 0) {
return await this.checkResourceStatus(resource_ids, use_group_api);
}
// Time-based routing
switch (time) {
case "current":
return await this.getCurrentOutages(resource);
case "scheduled":
return await this.getScheduledMaintenance(resource);
case "past":
return await this.getPastOutages(resource, limit || 100);
case "all":
return await this.getSystemAnnouncements(limit || 50);
default:
throw new Error(`Invalid time parameter: ${time}. Must be one of: current, scheduled, past, all`);
}
}
async handleResourceRead(request) {
const { uri } = request.params;
switch (uri) {
case "accessci://system-status": {
return {
contents: [
{
uri,
mimeType: "text/plain",
text: "ACCESS-CI System Status API - Monitor real-time status, outages, and maintenance for ACCESS-CI resources.",
},
],
};
}
case "accessci://outages/current": {
const currentOutages = await this.getCurrentOutages();
const content = currentOutages.content[0];
const text = content.type === "text" ? content.text : "";
return {
contents: [
{
uri,
mimeType: "application/json",
text,
},
],
};
}
case "accessci://outages/scheduled": {
const scheduledMaintenance = await this.getScheduledMaintenance();
const content = scheduledMaintenance.content[0];
const text = content.type === "text" ? content.text : "";
return {
contents: [
{
uri,
mimeType: "application/json",
text,
},
],
};
}
case "accessci://outages/past": {
const pastOutages = await this.getPastOutages();
const content = pastOutages.content[0];
const text = content.type === "text" ? content.text : "";
return {
contents: [
{
uri,
mimeType: "application/json",
text,
},
],
};
}
default:
throw new Error(`Unknown resource: ${uri}`);
}
}
async getCurrentOutages(resourceFilter) {
const response = await this.httpClient.get("/wh2/news/v1/affiliation/access-ci.org/current_outages/");
let outages = response.data.results || [];
// Filter by resource if specified
if (resourceFilter) {
const filter = resourceFilter.toLowerCase();
outages = outages.filter((outage) => outage.Subject?.toLowerCase().includes(filter) ||
outage.AffectedResources?.some((resource) => resource.ResourceName?.toLowerCase().includes(filter) ||
resource.ResourceID?.toString().includes(filter)));
}
// Initialize tracking variables
const affectedResources = new Set();
const severityCounts = { high: 0, medium: 0, low: 0, unknown: 0 };
// Enhance outages with status summary
const enhancedOutages = outages.map((outage) => {
// Track affected resources (use ResourceID as fallback if ResourceName is missing)
outage.AffectedResources?.forEach((resource) => {
const resourceIdentifier = resource.ResourceName || resource.ResourceID;
if (resourceIdentifier)
affectedResources.add(String(resourceIdentifier));
});
// Categorize severity (basic heuristic)
const subject = outage.Subject?.toLowerCase() || "";
let severity = "unknown";
if (subject.includes("emergency") || subject.includes("critical")) {
severity = "high";
}
else if (subject.includes("maintenance") || subject.includes("scheduled")) {
severity = "low";
}
else {
severity = "medium";
}
severityCounts[severity]++;
return {
...outage,
severity,
};
});
const summary = {
total_outages: outages.length,
affected_resources: Array.from(affectedResources),
severity_counts: severityCounts,
outages: enhancedOutages,
};
return {
content: [
{
type: "text",
text: JSON.stringify(summary, null, 2),
},
],
};
}
async getScheduledMaintenance(resourceFilter) {
const response = await this.httpClient.get("/wh2/news/v1/affiliation/access-ci.org/future_outages/");
let maintenance = response.data.results || [];
// Filter by resource if specified
if (resourceFilter) {
const filter = resourceFilter.toLowerCase();
maintenance = maintenance.filter((item) => item.Subject?.toLowerCase().includes(filter) ||
item.AffectedResources?.some((resource) => resource.ResourceName?.toLowerCase().includes(filter) ||
resource.ResourceID?.toString().includes(filter)));
}
// Sort by scheduled start time
maintenance.sort((a, b) => {
const dateA = new Date(a.OutageStart || "");
const dateB = new Date(b.OutageStart || "");
return dateA.getTime() - dateB.getTime();
});
// Initialize tracking variables
const affectedResources = new Set();
let upcoming24h = 0;
let upcomingWeek = 0;
const enhancedMaintenance = maintenance.map((item) => {
// Track affected resources
item.AffectedResources?.forEach((resource) => {
const resourceIdentifier = resource.ResourceName || resource.ResourceID;
if (resourceIdentifier)
affectedResources.add(String(resourceIdentifier));
});
// Check timing - use OutageStart for scheduling
const hasScheduledTime = !!item.OutageStart;
const startTime = new Date(item.OutageStart || "");
const now = new Date();
const hoursUntil = (startTime.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntil <= 24)
upcoming24h++;
if (hoursUntil <= 168)
upcomingWeek++; // 7 days * 24 hours
return {
...item,
scheduled_start: item.OutageStart,
scheduled_end: item.OutageEnd,
hours_until_start: Math.max(0, Math.round(hoursUntil)),
duration_hours: item.OutageEnd && item.OutageStart
? Math.round((new Date(item.OutageEnd).getTime() - new Date(item.OutageStart).getTime()) /
(1000 * 60 * 60))
: null,
has_scheduled_time: hasScheduledTime,
};
});
const summary = {
total_scheduled: maintenance.length,
upcoming_24h: upcoming24h,
upcoming_week: upcomingWeek,
affected_resources: Array.from(affectedResources),
maintenance: enhancedMaintenance,
};
return {
content: [
{
type: "text",
text: JSON.stringify(summary, null, 2),
},
],
};
}
async getPastOutages(resourceFilter, limit = 100) {
const response = await this.httpClient.get("/wh2/news/v1/affiliation/access-ci.org/past_outages/");
let pastOutages = response.data.results || [];
// Filter by resource if specified
if (resourceFilter) {
const filter = resourceFilter.toLowerCase();
pastOutages = pastOutages.filter((outage) => outage.Subject?.toLowerCase().includes(filter) ||
outage.AffectedResources?.some((resource) => resource.ResourceName?.toLowerCase().includes(filter) ||
resource.ResourceID?.toString().includes(filter)));
}
// Sort by outage end time (most recent first)
pastOutages.sort((a, b) => {
const dateA = new Date(a.OutageEnd || "");
const dateB = new Date(b.OutageEnd || "");
return dateB.getTime() - dateA.getTime();
});
// Apply limit
if (limit && pastOutages.length > limit) {
pastOutages = pastOutages.slice(0, limit);
}
// Initialize tracking variables
const affectedResources = new Set();
const outageTypes = new Set();
const recentOutages = pastOutages.filter((outage) => {
const endTime = new Date(outage.OutageEnd || "");
const daysAgo = (Date.now() - endTime.getTime()) / (1000 * 60 * 60 * 24);
return daysAgo <= 30; // Last 30 days
});
// Enhance outages with calculated fields
const enhancedOutages = pastOutages.map((outage) => {
// Track affected resources (use ResourceID as fallback if ResourceName is missing)
outage.AffectedResources?.forEach((resource) => {
const resourceIdentifier = resource.ResourceName || resource.ResourceID;
if (resourceIdentifier)
affectedResources.add(String(resourceIdentifier));
});
// Track outage types
if (outage.OutageType) {
outageTypes.add(outage.OutageType);
}
// Calculate duration
const startTime = new Date(outage.OutageStart || "");
const endTime = new Date(outage.OutageEnd || "");
const durationHours = Math.round((endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60));
// Calculate how long ago it ended
const daysAgo = Math.round((Date.now() - endTime.getTime()) / (1000 * 60 * 60 * 24));
return {
...outage,
outage_start: outage.OutageStart,
outage_end: outage.OutageEnd,
duration_hours: durationHours,
days_ago: daysAgo,
outage_type: outage.OutageType,
};
});
const summary = {
total_past_outages: enhancedOutages.length,
recent_outages_30_days: recentOutages.length,
affected_resources: Array.from(affectedResources),
outage_types: Array.from(outageTypes),
average_duration_hours: enhancedOutages.length > 0
? Math.round(enhancedOutages
.filter((o) => o.duration_hours && o.duration_hours > 0)
.reduce((sum, o) => sum + (o.duration_hours || 0), 0) /
enhancedOutages.filter((o) => o.duration_hours && o.duration_hours > 0).length)
: 0,
outages: enhancedOutages,
};
return {
content: [
{
type: "text",
text: JSON.stringify(summary, null, 2),
},
],
};
}
async getSystemAnnouncements(limit = 50) {
// Get current, future, and recent past announcements for comprehensive view
const [currentResponse, futureResponse, pastResponse] = await Promise.all([
this.httpClient.get("/wh2/news/v1/affiliation/access-ci.org/current_outages/"),
this.httpClient.get("/wh2/news/v1/affiliation/access-ci.org/future_outages/"),
this.httpClient.get("/wh2/news/v1/affiliation/access-ci.org/past_outages/"),
]);
const currentOutages = currentResponse.data.results || [];
const futureOutages = futureResponse.data.results || [];
const pastOutagesData = pastResponse.data.results || [];
// Filter recent past outages (last 30 days) for announcements
const recentPastOutages = pastOutagesData.filter((outage) => {
const endTime = new Date(outage.OutageEnd || "");
const daysAgo = (Date.now() - endTime.getTime()) / (1000 * 60 * 60 * 24);
return daysAgo <= 30;
});
// Combine all announcements and sort by most relevant date
const allAnnouncements = [
...currentOutages.map((item) => ({ ...item, category: "current" })),
...futureOutages.map((item) => ({ ...item, category: "scheduled" })),
...recentPastOutages.map((item) => ({
...item,
category: "recent_past",
})),
]
.sort((a, b) => {
// Sort by most relevant date: current first, then future by start time, then past by end time
if (a.category === "current" && b.category !== "current")
return -1;
if (b.category === "current" && a.category !== "current")
return 1;
const dateA = new Date(a.OutageStart || "");
const dateB = new Date(b.OutageStart || "");
return dateB.getTime() - dateA.getTime(); // Most recent first
})
.slice(0, limit);
const summary = {
total_announcements: allAnnouncements.length,
current_outages: currentOutages.length,
scheduled_maintenance: futureOutages.length,
recent_past_outages: recentPastOutages.length,
categories: {
current: allAnnouncements.filter((a) => a.category === "current").length,
scheduled: allAnnouncements.filter((a) => a.category === "scheduled").length,
recent_past: allAnnouncements.filter((a) => a.category === "recent_past").length,
},
announcements: allAnnouncements,
};
return {
content: [
{
type: "text",
text: JSON.stringify(summary, null, 2),
},
],
};
}
async checkResourceStatus(resourceIds, useGroupApi = false) {
if (!resourceIds || !Array.isArray(resourceIds) || resourceIds.length === 0) {
throw new Error("resource_ids parameter is required and must be a non-empty array of resource IDs");
}
// Resolve all resource names to IDs first
const resolvedIds = [];
const resolutionErrors = [];
for (const inputId of resourceIds) {
const resolved = await resolveResourceId(inputId, (query) => this.searchResourcesByName(query));
if (resolved.success) {
resolvedIds.push(resolved.id);
}
else {
resolutionErrors.push({ input: inputId, error: resolved.error });
}
}
// If any resolutions failed, return errors
if (resolutionErrors.length > 0) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "Could not resolve some resource names",
resolution_errors: resolutionErrors,
suggestions: [
"Use full resource IDs (e.g., 'anvil.purdue.access-ci.org')",
"Or use exact resource names (e.g., 'Anvil', 'Delta')",
],
}, null, 2),
},
],
};
}
if (useGroupApi) {
return await this.checkResourceStatusViaGroups(resolvedIds);
}
// Efficient approach: fetch raw current outages data once
const response = await this.httpClient.get("/wh2/news/v1/affiliation/access-ci.org/current_outages/");
const rawOutages = response.data.results || [];
const resourceStatus = resolvedIds.map((resourceId) => {
const affectedOutages = rawOutages.filter((outage) => outage.AffectedResources?.some((resource) => resource.ResourceID?.toString() === resourceId ||
resource.ResourceName?.toLowerCase().includes(resourceId.toLowerCase())));
let status = "operational";
let severity = null;
if (affectedOutages.length > 0) {
status = "affected";
// Get highest severity using same logic as getCurrentOutages
const severities = affectedOutages.map((outage) => {
const subject = outage.Subject?.toLowerCase() || "";
if (subject.includes("emergency") || subject.includes("critical")) {
return "high";
}
else if (subject.includes("maintenance") || subject.includes("scheduled")) {
return "low";
}
else {
return "medium";
}
});
if (severities.includes("high"))
severity = "high";
else if (severities.includes("medium"))
severity = "medium";
else
severity = "low";
}
return {
resource_id: resourceId,
status,
severity,
active_outages: affectedOutages.length,
outage_details: affectedOutages.map((outage) => ({
subject: outage.Subject,
severity,
})),
};
});
return {
content: [
{
type: "text",
text: JSON.stringify({
checked_at: new Date().toISOString(),
resources_checked: resolvedIds.length,
operational: resourceStatus.filter((r) => r.status === "operational").length,
affected: resourceStatus.filter((r) => r.status === "affected").length,
api_method: "direct_outages_check",
resource_status: resourceStatus,
}, null, 2),
},
],
};
}
async checkResourceStatusViaGroups(resourceIds) {
if (!resourceIds || !Array.isArray(resourceIds) || resourceIds.length === 0) {
throw new Error("resource_ids parameter is required and must be a non-empty array of resource IDs");
}
// Try to use the more efficient group-based API
const statusPromises = resourceIds.map(async (resourceId) => {
try {
const response = await this.httpClient.get(`/wh2/news/v1/info_groupid/${resourceId}/`);
const groupData = response.data.results || [];
const hasOutages = groupData.length > 0;
return {
resource_id: resourceId,
status: hasOutages ? "affected" : "operational",
severity: hasOutages ? "medium" : null,
active_outages: groupData.length,
outage_details: groupData.map((outage) => ({
subject: outage.Subject,
})),
api_method: "group_specific",
};
}
catch {
// Fallback to general check if group API fails
return {
resource_id: resourceId,
status: "unknown",
severity: null,
active_outages: 0,
outage_details: [],
error: `Group API failed for ${resourceId}`,
api_method: "group_specific_failed",
};
}
});
const resourceStatus = await Promise.all(statusPromises);
return {
content: [
{
type: "text",
text: JSON.stringify({
checked_at: new Date().toISOString(),
resources_checked: resourceIds.length,
operational: resourceStatus.filter((r) => r.status === "operational").length,
affected: resourceStatus.filter((r) => r.status === "affected").length,
unknown: resourceStatus.filter((r) => r.status === "unknown").length,
api_method: "resource_group_api",
resource_status: resourceStatus,
}, null, 2),
},
],
};
}
}