@access-mcp/announcements 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,307 @@
1
+ import { BaseAccessServer } from "@access-mcp/shared";
2
+ export class AnnouncementsServer extends BaseAccessServer {
3
+ constructor() {
4
+ super("access-announcements", "0.1.0", "https://support.access-ci.org");
5
+ }
6
+ getTools() {
7
+ return [
8
+ {
9
+ name: "get_announcements",
10
+ description: "Search ACCESS support announcements about system maintenance, service updates, outages, and community news. Use this when users ask about recent announcements, service status, or need to filter by dates, tags, or affinity groups.",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ tags: {
15
+ type: "string",
16
+ description: "Filter by specific topics (comma-separated). Real examples: 'gpu,nvidia', 'machine-learning,ai', 'maintenance,downtime', 'training,workshop', 'allocation-users', 'data-science', 'cloud-computing', 'bridges-2,anvil'"
17
+ },
18
+ ag: {
19
+ type: "string",
20
+ description: "Filter by system/community affinity group. Real examples: 'ACCESS Support', 'DELTA', 'Anvil', 'Bridges-2', 'CSSN (Computational Science Support Network)', 'Open OnDemand', 'Pegasus'"
21
+ },
22
+ affiliation: {
23
+ type: "string",
24
+ description: "Filter by organization affiliation (e.g., 'ACCESS')"
25
+ },
26
+ relative_start_date: {
27
+ type: "string",
28
+ description: "Filter announcements from a relative date. Examples: 'today', '-1 week', '-1 month', '-3 months', '-1 year'. Use negative values for past dates"
29
+ },
30
+ relative_end_date: {
31
+ type: "string",
32
+ description: "Filter announcements up to a relative date. Examples: 'now', 'today', '-1 week' (past), '+1 week' (future)"
33
+ },
34
+ start_date: {
35
+ type: "string",
36
+ description: "Filter announcements from exact date onwards (YYYY-MM-DD). Use when users specify dates like 'since January 1st, 2024' or 'from March 15th'"
37
+ },
38
+ end_date: {
39
+ type: "string",
40
+ description: "Filter announcements up to exact date (YYYY-MM-DD). Use when users specify dates like 'until December 31st, 2024' or 'before April 1st'"
41
+ },
42
+ limit: {
43
+ type: "number",
44
+ description: "Maximum number of announcements to return. Use smaller values (5-10) for quick overviews, larger values (20-50) for comprehensive searches",
45
+ default: 20
46
+ }
47
+ }
48
+ }
49
+ },
50
+ {
51
+ name: "get_announcements_by_tags",
52
+ description: "Find announcements about specific topics or systems. Use when users ask about particular subjects like 'GPU maintenance', 'machine learning resources', 'training workshops', or 'cloud computing updates'.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ tags: {
57
+ type: "string",
58
+ description: "Specific topics to search for (comma-separated). Popular examples: 'gpu,nvidia', 'machine-learning,ai', 'training,professional-development', 'data-science,python', 'cloud-computing', 'allocations-proposal'",
59
+ required: true
60
+ },
61
+ limit: {
62
+ type: "number",
63
+ description: "Maximum number of announcements to return",
64
+ default: 10
65
+ }
66
+ },
67
+ required: ["tags"]
68
+ }
69
+ },
70
+ {
71
+ name: "get_announcements_by_affinity_group",
72
+ description: "Get announcements for a specific ACCESS system or community group. Use when users ask about updates for particular resources like DELTA, Anvil, or Bridges-2, or community groups like CSSN.",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ ag: {
77
+ type: "string",
78
+ description: "The system or community group name (not the technical API ID). Examples: 'DELTA', 'Anvil', 'Bridges-2', 'ACCESS Support', 'Open OnDemand', 'Pegasus', 'CSSN (Computational Science Support Network)'",
79
+ required: true
80
+ },
81
+ limit: {
82
+ type: "number",
83
+ description: "Maximum number of announcements to return",
84
+ default: 10
85
+ }
86
+ },
87
+ required: ["ag"]
88
+ }
89
+ },
90
+ {
91
+ name: "get_recent_announcements",
92
+ description: "Get the latest ACCESS support announcements from a recent time period. Use this when users ask 'what's new?', 'recent updates', 'latest announcements', or want a general overview of current activity.",
93
+ inputSchema: {
94
+ type: "object",
95
+ properties: {
96
+ period: {
97
+ type: "string",
98
+ description: "How far back to look for announcements. Examples: '1 week', '2 weeks', '1 month', '3 months', '6 months'. Default is '1 month'",
99
+ default: "1 month"
100
+ },
101
+ limit: {
102
+ type: "number",
103
+ description: "Maximum number of announcements to return",
104
+ default: 10
105
+ }
106
+ }
107
+ }
108
+ }
109
+ ];
110
+ }
111
+ getResources() {
112
+ return [
113
+ {
114
+ uri: "accessci://announcements",
115
+ name: "ACCESS Support Announcements",
116
+ description: "Recent announcements and notifications from ACCESS support",
117
+ mimeType: "application/json"
118
+ }
119
+ ];
120
+ }
121
+ async handleToolCall(request) {
122
+ const { name, arguments: args } = request.params;
123
+ try {
124
+ switch (name) {
125
+ case "get_announcements":
126
+ return await this.getAnnouncements(args);
127
+ case "get_announcements_by_tags":
128
+ return await this.getAnnouncementsByTags(args.tags, args.limit);
129
+ case "get_announcements_by_affinity_group":
130
+ return await this.getAnnouncementsByAffinityGroup(args.ag, args.limit);
131
+ case "get_recent_announcements":
132
+ return await this.getRecentAnnouncements(args.period, args.limit);
133
+ default:
134
+ throw new Error(`Unknown tool: ${name}`);
135
+ }
136
+ }
137
+ catch (error) {
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text",
142
+ text: `Error: ${error.message}`
143
+ }
144
+ ]
145
+ };
146
+ }
147
+ }
148
+ async handleResourceRead(request) {
149
+ const { uri } = request.params;
150
+ if (uri === "accessci://announcements") {
151
+ try {
152
+ const announcements = await this.fetchAnnouncements({ limit: 10 });
153
+ return {
154
+ contents: [
155
+ {
156
+ uri,
157
+ mimeType: "application/json",
158
+ text: JSON.stringify(announcements, null, 2)
159
+ }
160
+ ]
161
+ };
162
+ }
163
+ catch (error) {
164
+ return {
165
+ contents: [
166
+ {
167
+ uri,
168
+ mimeType: "text/plain",
169
+ text: `Error loading announcements: ${error.message}`
170
+ }
171
+ ]
172
+ };
173
+ }
174
+ }
175
+ throw new Error(`Unknown resource: ${uri}`);
176
+ }
177
+ buildAnnouncementsUrl(filters) {
178
+ const params = new URLSearchParams();
179
+ if (filters.tags) {
180
+ params.append("tags", filters.tags);
181
+ }
182
+ if (filters.ag) {
183
+ params.append("ag", filters.ag);
184
+ }
185
+ if (filters.affiliation) {
186
+ params.append("affiliation", filters.affiliation);
187
+ }
188
+ if (filters.relative_start_date) {
189
+ params.append("relative_start_date", filters.relative_start_date);
190
+ }
191
+ if (filters.relative_end_date) {
192
+ params.append("relative_end_date", filters.relative_end_date);
193
+ }
194
+ if (filters.start_date) {
195
+ params.append("start_date", filters.start_date);
196
+ }
197
+ if (filters.end_date) {
198
+ params.append("end_date", filters.end_date);
199
+ }
200
+ return `/api/2.1/announcements?${params.toString()}`;
201
+ }
202
+ async fetchAnnouncements(filters) {
203
+ const url = this.buildAnnouncementsUrl(filters);
204
+ const response = await this.httpClient.get(url);
205
+ if (response.status !== 200) {
206
+ throw new Error(`API Error ${response.status}: ${response.statusText}`);
207
+ }
208
+ const announcements = response.data || [];
209
+ return this.enhanceAnnouncements(announcements);
210
+ }
211
+ enhanceAnnouncements(rawAnnouncements) {
212
+ return rawAnnouncements.map(announcement => ({
213
+ ...announcement,
214
+ date: announcement.field_published_date,
215
+ tags: announcement.custom_announcement_tags
216
+ ? announcement.custom_announcement_tags.split(',').map((t) => t.trim()).filter((t) => t)
217
+ : [],
218
+ formatted_date: announcement.field_published_date
219
+ ? new Date(announcement.field_published_date).toLocaleDateString('en-US', {
220
+ year: 'numeric',
221
+ month: 'long',
222
+ day: 'numeric'
223
+ })
224
+ : '',
225
+ body_preview: announcement.body
226
+ ? announcement.body.replace(/<[^>]*>/g, '').substring(0, 200) + '...'
227
+ : '',
228
+ affinity_groups: announcement.custom_announcement_ag
229
+ ? announcement.custom_announcement_ag.split(',').map((g) => g.trim()).filter((g) => g)
230
+ : []
231
+ }));
232
+ }
233
+ async getAnnouncements(filters) {
234
+ const announcements = await this.fetchAnnouncements(filters);
235
+ const limited = filters.limit ? announcements.slice(0, filters.limit) : announcements;
236
+ // Build filters_applied object
237
+ const filters_applied = {};
238
+ if (filters.tags)
239
+ filters_applied.tags = filters.tags;
240
+ if (filters.ag)
241
+ filters_applied.affinity_group = filters.ag;
242
+ if (filters.relative_start_date || filters.relative_end_date) {
243
+ filters_applied.date_range = `${filters.relative_start_date || 'any'} to ${filters.relative_end_date || 'now'}`;
244
+ }
245
+ if (filters.start_date || filters.end_date) {
246
+ filters_applied.date_range = `${filters.start_date || 'any'} to ${filters.end_date || 'now'}`;
247
+ }
248
+ if (filters.limit)
249
+ filters_applied.limit = filters.limit;
250
+ const result = {
251
+ total_announcements: announcements.length,
252
+ filtered_announcements: limited.length,
253
+ announcements: limited,
254
+ filters_applied,
255
+ popular_tags: this.getPopularTags(limited),
256
+ message: limited.length === 0 ? "No announcements found matching the filters" : undefined
257
+ };
258
+ return {
259
+ content: [
260
+ {
261
+ type: "text",
262
+ text: JSON.stringify(result, null, 2)
263
+ }
264
+ ]
265
+ };
266
+ }
267
+ async getAnnouncementsByTags(tags, limit = 10) {
268
+ return await this.getAnnouncements({ tags, limit });
269
+ }
270
+ async getAnnouncementsByAffinityGroup(ag, limit = 10) {
271
+ return await this.getAnnouncements({ ag, limit });
272
+ }
273
+ async getRecentAnnouncements(period = "1 month", limit = 10) {
274
+ const relativeStart = `-${period}`;
275
+ return await this.getAnnouncements({
276
+ relative_start_date: relativeStart,
277
+ relative_end_date: "now",
278
+ limit
279
+ });
280
+ }
281
+ getPopularTags(announcements) {
282
+ const tagCounts = {};
283
+ announcements.forEach(announcement => {
284
+ if (announcement.tags) {
285
+ announcement.tags.forEach((tag) => {
286
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
287
+ });
288
+ }
289
+ });
290
+ return Object.entries(tagCounts)
291
+ .sort(([, a], [, b]) => b - a)
292
+ .slice(0, 10)
293
+ .map(([tag]) => tag);
294
+ }
295
+ getAffinityGroups(announcements) {
296
+ const groups = new Set();
297
+ announcements.forEach(announcement => {
298
+ if (announcement.affinity_groups && Array.isArray(announcement.affinity_groups)) {
299
+ announcement.affinity_groups.forEach((group) => {
300
+ if (group)
301
+ groups.add(group);
302
+ });
303
+ }
304
+ });
305
+ return Array.from(groups);
306
+ }
307
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@access-mcp/announcements",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for ACCESS Support Announcements API",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "access-mcp-announcements": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsc --watch",
13
+ "start": "node dist/index.js",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "test:integration": "vitest run --config vitest.integration.config.ts",
17
+ "test:all": "vitest run && vitest run --config vitest.integration.config.ts",
18
+ "test:coverage": "vitest run --coverage"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "access-ci",
23
+ "announcements",
24
+ "support"
25
+ ],
26
+ "author": "ACCESS-CI",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@access-mcp/shared": "file:../shared",
30
+ "@modelcontextprotocol/sdk": "^0.5.0",
31
+ "axios": "^1.7.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^20.0.0",
35
+ "typescript": "^5.0.0",
36
+ "vitest": "^1.0.0"
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { AnnouncementsServer } from "./server.js";
4
+
5
+ async function main() {
6
+ // Check if we should run as HTTP server (for deployment)
7
+ const port = process.env.PORT;
8
+
9
+ const server = new AnnouncementsServer();
10
+
11
+ if (port) {
12
+ // Running in HTTP mode (deployment)
13
+ await server.start({ httpPort: parseInt(port) });
14
+ // Keep the process running in HTTP mode
15
+ process.on('SIGINT', () => {
16
+ console.log('Shutting down server...');
17
+ process.exit(0);
18
+ });
19
+ // Keep the event loop alive
20
+ setInterval(() => {}, 1000 * 60 * 60); // Heartbeat every hour
21
+ } else {
22
+ // Running in MCP mode (stdio)
23
+ await server.start();
24
+ }
25
+ }
26
+
27
+ main().catch((error) => {
28
+ // Log errors to stderr and exit
29
+ console.error("Server error:", error);
30
+ process.exit(1);
31
+ });
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AnnouncementsServer } from "./server.js";
3
+
4
+ /**
5
+ * Integration tests for AnnouncementsServer
6
+ * These tests make actual API calls to the ACCESS Support API
7
+ */
8
+ describe("AnnouncementsServer Integration Tests", () => {
9
+ let server: AnnouncementsServer;
10
+
11
+ beforeEach(() => {
12
+ server = new AnnouncementsServer();
13
+ });
14
+
15
+ describe("get_announcements", () => {
16
+ it("should fetch real announcements from API", async () => {
17
+ const result = await server["handleToolCall"]({
18
+ params: {
19
+ name: "get_announcements",
20
+ arguments: {
21
+ limit: 5,
22
+ },
23
+ },
24
+ } as any);
25
+
26
+ expect(result.isError).toBeFalsy();
27
+ const responseData = JSON.parse(result.content[0].text);
28
+
29
+ // Check structure
30
+ expect(responseData).toHaveProperty("total_announcements");
31
+ expect(responseData).toHaveProperty("announcements");
32
+ expect(responseData).toHaveProperty("popular_tags");
33
+ expect(responseData).toHaveProperty("filters_applied");
34
+
35
+ // Announcements should be an array
36
+ expect(Array.isArray(responseData.announcements)).toBe(true);
37
+
38
+ // If there are announcements, check their structure
39
+ if (responseData.announcements.length > 0) {
40
+ const firstAnnouncement = responseData.announcements[0];
41
+ expect(firstAnnouncement).toHaveProperty("title");
42
+ expect(firstAnnouncement).toHaveProperty("body");
43
+ expect(firstAnnouncement).toHaveProperty("date");
44
+ expect(firstAnnouncement).toHaveProperty("formatted_date");
45
+ expect(firstAnnouncement).toHaveProperty("tags");
46
+ expect(Array.isArray(firstAnnouncement.tags)).toBe(true);
47
+ }
48
+ }, 10000);
49
+
50
+ it("should filter by tags", async () => {
51
+ const result = await server["handleToolCall"]({
52
+ params: {
53
+ name: "get_announcements_by_tags",
54
+ arguments: {
55
+ tags: "maintenance",
56
+ limit: 3,
57
+ },
58
+ },
59
+ } as any);
60
+
61
+ expect(result.isError).toBeFalsy();
62
+ const responseData = JSON.parse(result.content[0].text);
63
+
64
+ expect(responseData).toHaveProperty("announcements");
65
+ expect(Array.isArray(responseData.announcements)).toBe(true);
66
+
67
+ // If maintenance announcements exist, they should contain the tag
68
+ if (responseData.announcements.length > 0) {
69
+ const hasMaintenanceTag = responseData.announcements.some((ann: any) =>
70
+ ann.tags && ann.tags.some((tag: string) =>
71
+ tag.toLowerCase().includes("maintenance")
72
+ )
73
+ );
74
+ // This might not always be true if the API doesn't have maintenance announcements
75
+ console.log("Found maintenance announcements:", hasMaintenanceTag);
76
+ }
77
+ }, 10000);
78
+
79
+ it("should handle date range filters", async () => {
80
+ const result = await server["handleToolCall"]({
81
+ params: {
82
+ name: "get_announcements",
83
+ arguments: {
84
+ relative_start_date: "-1month",
85
+ relative_end_date: "today",
86
+ limit: 10,
87
+ },
88
+ },
89
+ } as any);
90
+
91
+ expect(result.isError).toBeFalsy();
92
+ const responseData = JSON.parse(result.content[0].text);
93
+
94
+ expect(responseData).toHaveProperty("announcements");
95
+ expect(responseData.filters_applied).toHaveProperty("date_range");
96
+
97
+ // All announcements should be within the last month
98
+ if (responseData.announcements.length > 0) {
99
+ const oneMonthAgo = new Date();
100
+ oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
101
+
102
+ responseData.announcements.forEach((ann: any) => {
103
+ const annDate = new Date(ann.date);
104
+ expect(annDate.getTime()).toBeGreaterThanOrEqual(oneMonthAgo.getTime());
105
+ });
106
+ }
107
+ }, 10000);
108
+ });
109
+
110
+ describe("get_recent_announcements", () => {
111
+ it("should fetch announcements from the past week", async () => {
112
+ const result = await server["handleToolCall"]({
113
+ params: {
114
+ name: "get_recent_announcements",
115
+ arguments: {
116
+ period: "1 week",
117
+ },
118
+ },
119
+ } as any);
120
+
121
+ expect(result.isError).toBeFalsy();
122
+ const responseData = JSON.parse(result.content[0].text);
123
+
124
+ expect(responseData).toHaveProperty("announcements");
125
+ expect(responseData.filters_applied.date_range).toContain("week");
126
+
127
+ // Check that announcements are from the past week if any exist
128
+ if (responseData.announcements.length > 0) {
129
+ const oneWeekAgo = new Date();
130
+ oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
131
+
132
+ responseData.announcements.forEach((ann: any) => {
133
+ const annDate = new Date(ann.date);
134
+ expect(annDate.getTime()).toBeGreaterThanOrEqual(oneWeekAgo.getTime());
135
+ });
136
+ }
137
+ }, 10000);
138
+
139
+ it("should handle today filter", async () => {
140
+ const result = await server["handleToolCall"]({
141
+ params: {
142
+ name: "get_recent_announcements",
143
+ arguments: {
144
+ period: "0 days",
145
+ },
146
+ },
147
+ } as any);
148
+
149
+ expect(result.isError).toBeFalsy();
150
+ const responseData = JSON.parse(result.content[0].text);
151
+
152
+ expect(responseData).toHaveProperty("announcements");
153
+ expect(responseData.filters_applied.date_range).toContain("0 days");
154
+
155
+ // Today's announcements might be empty, that's okay
156
+ expect(Array.isArray(responseData.announcements)).toBe(true);
157
+ }, 10000);
158
+ });
159
+
160
+ describe("get_announcements_by_affinity_group", () => {
161
+ it("should handle affinity group filtering", async () => {
162
+ // We don't know specific affinity group IDs, so test with a made-up one
163
+ const result = await server["handleToolCall"]({
164
+ params: {
165
+ name: "get_announcements_by_affinity_group",
166
+ arguments: {
167
+ ag: "test-group-123",
168
+ limit: 5,
169
+ },
170
+ },
171
+ } as any);
172
+
173
+ expect(result.isError).toBeFalsy();
174
+ const responseData = JSON.parse(result.content[0].text);
175
+
176
+ // Should return a valid response even if no announcements match
177
+ expect(responseData).toHaveProperty("announcements");
178
+ expect(Array.isArray(responseData.announcements)).toBe(true);
179
+ expect(responseData.filters_applied).toHaveProperty("affinity_group");
180
+ expect(responseData.filters_applied.affinity_group).toBe("test-group-123");
181
+ }, 10000);
182
+ });
183
+
184
+ describe("API Error Handling", () => {
185
+ it("should handle invalid parameters gracefully", async () => {
186
+ const result = await server["handleToolCall"]({
187
+ params: {
188
+ name: "get_announcements",
189
+ arguments: {
190
+ beginning_date: "invalid-date",
191
+ },
192
+ },
193
+ } as any);
194
+
195
+ // The API might accept this or reject it
196
+ // Just verify we get a response
197
+ expect(result).toBeDefined();
198
+ expect(result.content).toBeDefined();
199
+ }, 10000);
200
+
201
+ it("should handle empty results", async () => {
202
+ const result = await server["handleToolCall"]({
203
+ params: {
204
+ name: "get_announcements",
205
+ arguments: {
206
+ tags: "nonexistent-tag-xyz-123-456",
207
+ exact_match: true,
208
+ },
209
+ },
210
+ } as any);
211
+
212
+ expect(result.isError).toBeFalsy();
213
+ const responseData = JSON.parse(result.content[0].text);
214
+
215
+ expect(responseData.total_announcements).toBe(0);
216
+ expect(responseData.announcements).toEqual([]);
217
+ expect(responseData.message).toContain("No announcements found");
218
+ }, 10000);
219
+ });
220
+ });