@access-mcp/xdmod 0.6.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.
@@ -0,0 +1,507 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import { createRequire } from "module";
3
+ const require = createRequire(import.meta.url);
4
+ const { version } = require("../../package.json");
5
+ // Mock fetch globally
6
+ const mockFetch = vi.fn();
7
+ vi.stubGlobal("fetch", mockFetch);
8
+ // Prevent process.exit from killing vitest
9
+ vi.spyOn(process, "exit").mockImplementation((() => { }));
10
+ // Mock the base server — include start() so main() doesn't crash
11
+ vi.mock("@access-mcp/shared", () => ({
12
+ BaseAccessServer: class MockBaseAccessServer {
13
+ serverName;
14
+ version;
15
+ baseURL;
16
+ constructor(serverName, version, baseURL) {
17
+ this.serverName = serverName;
18
+ this.version = version;
19
+ this.baseURL = baseURL;
20
+ }
21
+ httpClient = { get: vi.fn() };
22
+ async start() { }
23
+ },
24
+ handleApiError: vi.fn((error) => error instanceof Error ? error.message : "Unknown error"),
25
+ }));
26
+ // Sample menu data matching XDMoD API response format
27
+ const SAMPLE_MENU_DATA = [
28
+ { text: "by Resource", realm: "Jobs", group_by: "resource", node_type: "group_by" },
29
+ { text: "by Person", realm: "Jobs", group_by: "person", node_type: "group_by" },
30
+ { text: "by PI", realm: "Jobs", group_by: "pi", node_type: "group_by" },
31
+ { text: "by Institution", realm: "Jobs", group_by: "institution", node_type: "group_by" },
32
+ { text: "by Resource", realm: "SUPREMM", group_by: "resource", node_type: "group_by" },
33
+ { text: "by Person", realm: "SUPREMM", group_by: "person", node_type: "group_by" },
34
+ { text: "by Resource", realm: "Cloud", group_by: "resource", node_type: "group_by" },
35
+ ];
36
+ const SAMPLE_DIMENSION_VALUES = {
37
+ totalCount: 3,
38
+ data: [
39
+ { id: "delta.ncsa.xsede.org", name: "Delta", short_name: "Delta" },
40
+ { id: "bridges2.psc.xsede.org", name: "Bridges-2", short_name: "Bridges-2" },
41
+ { id: "expanse.sdsc.xsede.org", name: "Expanse", short_name: "Expanse" },
42
+ ],
43
+ };
44
+ const SAMPLE_CHART_DATA = {
45
+ data: [
46
+ {
47
+ chart_title: "Total CPU Hours",
48
+ group_description: "All Jobs",
49
+ description: "CPU usage over time",
50
+ },
51
+ ],
52
+ };
53
+ // Helper: create a mock Response
54
+ function mockResponse(body, ok = true, status = 200) {
55
+ return {
56
+ ok,
57
+ status,
58
+ statusText: ok ? "OK" : "Error",
59
+ json: async () => body,
60
+ text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
61
+ arrayBuffer: async () => new ArrayBuffer(0),
62
+ headers: new Headers(),
63
+ };
64
+ }
65
+ // Helper to extract text from CallToolResult content
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ function getText(result) {
68
+ const item = result.content[0];
69
+ return item.text ?? "";
70
+ }
71
+ // Import the server class (after mocks are in place)
72
+ const { XDMoDMetricsServer } = await import("../index.js");
73
+ describe("XDMoDMetricsServer", () => {
74
+ let server;
75
+ beforeEach(() => {
76
+ vi.clearAllMocks();
77
+ server = new XDMoDMetricsServer();
78
+ // Clear the menu cache between tests
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ server.menuCache = null;
81
+ });
82
+ describe("basic configuration", () => {
83
+ test("should be instantiable", () => {
84
+ expect(server).toBeDefined();
85
+ expect(server).toBeInstanceOf(XDMoDMetricsServer);
86
+ });
87
+ test("should have correct server name", () => {
88
+ expect(server["serverName"]).toBe("xdmod");
89
+ });
90
+ test("should have correct version", () => {
91
+ expect(server["version"]).toBe(version);
92
+ });
93
+ test("should have correct base URL", () => {
94
+ expect(server["baseURL"]).toBe("https://xdmod.access-ci.org");
95
+ });
96
+ });
97
+ describe("getTools()", () => {
98
+ test("should return 6 tools", () => {
99
+ const tools = server["getTools"]();
100
+ expect(tools).toHaveLength(6);
101
+ });
102
+ test("should include chart tools", () => {
103
+ const tools = server["getTools"]();
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ const names = tools.map((t) => t.name);
106
+ expect(names).toContain("get_chart_data");
107
+ expect(names).toContain("get_chart_image");
108
+ expect(names).toContain("get_chart_link");
109
+ });
110
+ test("should include discovery tools", () => {
111
+ const tools = server["getTools"]();
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ const names = tools.map((t) => t.name);
114
+ expect(names).toContain("describe_realms");
115
+ expect(names).toContain("describe_fields");
116
+ expect(names).toContain("get_dimension_values");
117
+ });
118
+ test("describe_realms should require no parameters", () => {
119
+ const tools = server["getTools"]();
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ const tool = tools.find((t) => t.name === "describe_realms");
122
+ expect(tool?.inputSchema.required).toEqual([]);
123
+ });
124
+ test("describe_fields should require realm parameter", () => {
125
+ const tools = server["getTools"]();
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ const tool = tools.find((t) => t.name === "describe_fields");
128
+ expect(tool?.inputSchema.required).toEqual(["realm"]);
129
+ });
130
+ test("get_dimension_values should require realm and dimension", () => {
131
+ const tools = server["getTools"]();
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ const tool = tools.find((t) => t.name === "get_dimension_values");
134
+ expect(tool?.inputSchema.required).toEqual(["realm", "dimension"]);
135
+ });
136
+ });
137
+ describe("handleToolCall()", () => {
138
+ test("should throw on unknown tool", async () => {
139
+ await expect(server["handleToolCall"]({
140
+ method: "tools/call",
141
+ params: { name: "nonexistent_tool", arguments: {} },
142
+ })).rejects.toThrow("Unknown tool: nonexistent_tool");
143
+ });
144
+ });
145
+ describe("fetchMenus()", () => {
146
+ test("should call XDMoD API with correct parameters", async () => {
147
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
148
+ await server["fetchMenus"]();
149
+ expect(mockFetch).toHaveBeenCalledTimes(1);
150
+ const [url, options] = mockFetch.mock.calls[0];
151
+ expect(url).toBe("https://xdmod.access-ci.org/controllers/user_interface.php");
152
+ expect(options.method).toBe("POST");
153
+ const body = options.body;
154
+ expect(body.get("operation")).toBe("get_menus");
155
+ expect(body.get("public_user")).toBe("true");
156
+ expect(body.get("node")).toBe("category_");
157
+ });
158
+ test("should cache menu results", async () => {
159
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
160
+ const first = await server["fetchMenus"]();
161
+ const second = await server["fetchMenus"]();
162
+ expect(mockFetch).toHaveBeenCalledTimes(1);
163
+ expect(first).toEqual(second);
164
+ });
165
+ test("should refresh cache after TTL expires", async () => {
166
+ mockFetch.mockResolvedValue(mockResponse({ data: SAMPLE_MENU_DATA }));
167
+ await server["fetchMenus"]();
168
+ // Simulate expired cache
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ server.menuCache.timestamp = Date.now() - 1000 * 60 * 31;
171
+ await server["fetchMenus"]();
172
+ expect(mockFetch).toHaveBeenCalledTimes(2);
173
+ });
174
+ test("should throw on API error", async () => {
175
+ mockFetch.mockResolvedValueOnce(mockResponse(null, false, 500));
176
+ await expect(server["fetchMenus"]()).rejects.toThrow("Failed to fetch XDMoD menus: HTTP 500");
177
+ });
178
+ test("should handle response without data wrapper", async () => {
179
+ mockFetch.mockResolvedValueOnce(mockResponse(SAMPLE_MENU_DATA));
180
+ const result = await server["fetchMenus"]();
181
+ expect(result).toEqual(SAMPLE_MENU_DATA);
182
+ });
183
+ });
184
+ describe("describe_realms tool", () => {
185
+ test("should list realms from API and static reference", async () => {
186
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
187
+ const result = await server["handleToolCall"]({
188
+ method: "tools/call",
189
+ params: { name: "describe_realms", arguments: {} },
190
+ });
191
+ const text = getText(result);
192
+ expect(text).toContain("Jobs");
193
+ expect(text).toContain("SUPREMM");
194
+ expect(text).toContain("Cloud");
195
+ expect(text).toContain("Storage");
196
+ expect(text).toContain("reference only");
197
+ });
198
+ test("should include dimension and statistics counts", async () => {
199
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
200
+ const result = await server["handleToolCall"]({
201
+ method: "tools/call",
202
+ params: { name: "describe_realms", arguments: {} },
203
+ });
204
+ const text = getText(result);
205
+ expect(text).toContain("Dimensions");
206
+ expect(text).toContain("Statistics");
207
+ });
208
+ test("should propagate API errors", async () => {
209
+ mockFetch.mockResolvedValueOnce(mockResponse(null, false, 500));
210
+ await expect(server["handleToolCall"]({
211
+ method: "tools/call",
212
+ params: { name: "describe_realms", arguments: {} },
213
+ })).rejects.toThrow("Failed to describe realms");
214
+ });
215
+ });
216
+ describe("describe_fields tool", () => {
217
+ test("should return dimensions and statistics for Jobs realm", async () => {
218
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
219
+ const result = await server["handleToolCall"]({
220
+ method: "tools/call",
221
+ params: { name: "describe_fields", arguments: { realm: "Jobs" } },
222
+ });
223
+ const text = getText(result);
224
+ expect(text).toContain("resource");
225
+ expect(text).toContain("person");
226
+ expect(text).toContain("pi");
227
+ expect(text).toContain("institution");
228
+ expect(text).toContain("total_cpu_hours");
229
+ expect(text).toContain("job_count");
230
+ expect(text).toContain("CPU Hours: Total");
231
+ });
232
+ test("should return SUPREMM statistics", async () => {
233
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
234
+ const result = await server["handleToolCall"]({
235
+ method: "tools/call",
236
+ params: { name: "describe_fields", arguments: { realm: "SUPREMM" } },
237
+ });
238
+ const text = getText(result);
239
+ expect(text).toContain("gpu_time");
240
+ expect(text).toContain("GPU Hours: Total");
241
+ expect(text).toContain("avg_percent_gpu_usage");
242
+ });
243
+ test("should return error for unknown realm", async () => {
244
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
245
+ const result = await server["handleToolCall"]({
246
+ method: "tools/call",
247
+ params: { name: "describe_fields", arguments: { realm: "FakeRealm" } },
248
+ });
249
+ const text = getText(result);
250
+ expect(text).toContain("not found");
251
+ expect(text).toContain("Available realms");
252
+ });
253
+ test("should use static data when realm has no API entries", async () => {
254
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: SAMPLE_MENU_DATA }));
255
+ const result = await server["handleToolCall"]({
256
+ method: "tools/call",
257
+ params: { name: "describe_fields", arguments: { realm: "Storage" } },
258
+ });
259
+ const text = getText(result);
260
+ expect(text).toContain("Storage");
261
+ expect(text).toContain("avg_logical_usage");
262
+ });
263
+ test("should deduplicate dimension entries", async () => {
264
+ const dupeMenus = [
265
+ { realm: "Jobs", group_by: "resource", text: "By Resource", id: "resource" },
266
+ { realm: "Jobs", group_by: "resource", text: "By Resource (dup)", id: "resource" },
267
+ ];
268
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: dupeMenus }));
269
+ const result = await server["handleToolCall"]({
270
+ method: "tools/call",
271
+ params: { name: "describe_fields", arguments: { realm: "Jobs" } },
272
+ });
273
+ const text = getText(result);
274
+ const matches = text.match(/`resource`/g);
275
+ expect(matches).toHaveLength(1);
276
+ });
277
+ });
278
+ describe("get_dimension_values tool", () => {
279
+ test("should call metric_explorer API with correct parameters", async () => {
280
+ mockFetch.mockResolvedValueOnce(mockResponse(SAMPLE_DIMENSION_VALUES));
281
+ await server["handleToolCall"]({
282
+ method: "tools/call",
283
+ params: {
284
+ name: "get_dimension_values",
285
+ arguments: { realm: "Jobs", dimension: "resource" },
286
+ },
287
+ });
288
+ const [url, options] = mockFetch.mock.calls[0];
289
+ expect(url).toBe("https://xdmod.access-ci.org/controllers/metric_explorer.php");
290
+ expect(options.method).toBe("POST");
291
+ const body = options.body;
292
+ expect(body.get("operation")).toBe("get_dimension");
293
+ expect(body.get("public_user")).toBe("true");
294
+ expect(body.get("realm")).toBe("Jobs");
295
+ expect(body.get("dimension_id")).toBe("resource");
296
+ expect(body.get("limit")).toBe("200");
297
+ });
298
+ test("should format dimension values in response", async () => {
299
+ mockFetch.mockResolvedValueOnce(mockResponse(SAMPLE_DIMENSION_VALUES));
300
+ const result = await server["handleToolCall"]({
301
+ method: "tools/call",
302
+ params: {
303
+ name: "get_dimension_values",
304
+ arguments: { realm: "Jobs", dimension: "resource" },
305
+ },
306
+ });
307
+ const text = getText(result);
308
+ expect(text).toContain("Delta");
309
+ expect(text).toContain("Bridges-2");
310
+ expect(text).toContain("Expanse");
311
+ expect(text).toContain("3 total");
312
+ });
313
+ test("should use custom limit parameter", async () => {
314
+ mockFetch.mockResolvedValueOnce(mockResponse(SAMPLE_DIMENSION_VALUES));
315
+ await server["handleToolCall"]({
316
+ method: "tools/call",
317
+ params: {
318
+ name: "get_dimension_values",
319
+ arguments: { realm: "Jobs", dimension: "resource", limit: 50 },
320
+ },
321
+ });
322
+ const body = mockFetch.mock.calls[0][1].body;
323
+ expect(body.get("limit")).toBe("50");
324
+ });
325
+ test("should show truncation message when more values exist", async () => {
326
+ const manyValues = {
327
+ totalCount: 500,
328
+ data: [
329
+ { id: "1", name: "Resource A" },
330
+ { id: "2", name: "Resource B" },
331
+ ],
332
+ };
333
+ mockFetch.mockResolvedValueOnce(mockResponse(manyValues));
334
+ const result = await server["handleToolCall"]({
335
+ method: "tools/call",
336
+ params: {
337
+ name: "get_dimension_values",
338
+ arguments: { realm: "Jobs", dimension: "resource" },
339
+ },
340
+ });
341
+ const text = getText(result);
342
+ expect(text).toContain("498 more");
343
+ });
344
+ test("should handle empty dimension values", async () => {
345
+ mockFetch.mockResolvedValueOnce(mockResponse({ totalCount: 0, data: [] }));
346
+ const result = await server["handleToolCall"]({
347
+ method: "tools/call",
348
+ params: {
349
+ name: "get_dimension_values",
350
+ arguments: { realm: "Jobs", dimension: "nonexistent" },
351
+ },
352
+ });
353
+ const text = getText(result);
354
+ expect(text).toContain("No values found");
355
+ });
356
+ test("should throw on API error", async () => {
357
+ mockFetch.mockResolvedValueOnce(mockResponse(null, false, 500));
358
+ await expect(server["handleToolCall"]({
359
+ method: "tools/call",
360
+ params: {
361
+ name: "get_dimension_values",
362
+ arguments: { realm: "Jobs", dimension: "resource" },
363
+ },
364
+ })).rejects.toThrow("Failed to get dimension values");
365
+ });
366
+ });
367
+ describe("get_chart_data tool", () => {
368
+ test("should call user_interface API with correct chart parameters", async () => {
369
+ mockFetch.mockResolvedValueOnce(mockResponse(SAMPLE_CHART_DATA));
370
+ await server["handleToolCall"]({
371
+ method: "tools/call",
372
+ params: {
373
+ name: "get_chart_data",
374
+ arguments: {
375
+ realm: "Jobs",
376
+ group_by: "none",
377
+ statistic: "total_cpu_hours",
378
+ start_date: "2024-01-01",
379
+ end_date: "2024-12-31",
380
+ },
381
+ },
382
+ });
383
+ const [url, options] = mockFetch.mock.calls[0];
384
+ expect(url).toBe("https://xdmod.access-ci.org/controllers/user_interface.php");
385
+ const body = options.body;
386
+ expect(body.get("operation")).toBe("get_charts");
387
+ expect(body.get("public_user")).toBe("true");
388
+ expect(body.get("realm")).toBe("Jobs");
389
+ expect(body.get("group_by")).toBe("none");
390
+ expect(body.get("statistic")).toBe("total_cpu_hours");
391
+ expect(body.get("start_date")).toBe("2024-01-01");
392
+ expect(body.get("end_date")).toBe("2024-12-31");
393
+ });
394
+ test("should format chart data response", async () => {
395
+ mockFetch.mockResolvedValueOnce(mockResponse(SAMPLE_CHART_DATA));
396
+ const result = await server["handleToolCall"]({
397
+ method: "tools/call",
398
+ params: {
399
+ name: "get_chart_data",
400
+ arguments: {
401
+ realm: "Jobs",
402
+ group_by: "none",
403
+ statistic: "total_cpu_hours",
404
+ start_date: "2024-01-01",
405
+ end_date: "2024-12-31",
406
+ },
407
+ },
408
+ });
409
+ const text = getText(result);
410
+ expect(text).toContain("total_cpu_hours");
411
+ expect(text).toContain("Jobs");
412
+ expect(text).toContain("Total CPU Hours");
413
+ });
414
+ test("should handle empty chart data", async () => {
415
+ mockFetch.mockResolvedValueOnce(mockResponse({ data: [] }));
416
+ const result = await server["handleToolCall"]({
417
+ method: "tools/call",
418
+ params: {
419
+ name: "get_chart_data",
420
+ arguments: {
421
+ realm: "Jobs",
422
+ group_by: "none",
423
+ statistic: "total_cpu_hours",
424
+ start_date: "2024-01-01",
425
+ end_date: "2024-12-31",
426
+ },
427
+ },
428
+ });
429
+ const text = getText(result);
430
+ expect(text).toContain("No data available");
431
+ });
432
+ });
433
+ describe("get_chart_link tool", () => {
434
+ test("should generate correct portal URL", async () => {
435
+ const result = await server["handleToolCall"]({
436
+ method: "tools/call",
437
+ params: {
438
+ name: "get_chart_link",
439
+ arguments: {
440
+ realm: "Jobs",
441
+ group_by: "resource",
442
+ statistic: "total_cpu_hours",
443
+ },
444
+ },
445
+ });
446
+ const text = getText(result);
447
+ expect(text).toContain("https://xdmod.access-ci.org/index.php#tg_usage");
448
+ expect(text).toContain("realm=Jobs");
449
+ expect(text).toContain("group_by=resource");
450
+ expect(text).toContain("statistic=total_cpu_hours");
451
+ });
452
+ });
453
+ describe("get_chart_image tool", () => {
454
+ test("should request PNG and return image content", async () => {
455
+ const fakeBuffer = new ArrayBuffer(8);
456
+ mockFetch.mockResolvedValueOnce({
457
+ ok: true,
458
+ status: 200,
459
+ arrayBuffer: async () => fakeBuffer,
460
+ headers: new Headers(),
461
+ });
462
+ const result = await server["handleToolCall"]({
463
+ method: "tools/call",
464
+ params: {
465
+ name: "get_chart_image",
466
+ arguments: {
467
+ realm: "Jobs",
468
+ group_by: "none",
469
+ statistic: "total_cpu_hours",
470
+ start_date: "2024-01-01",
471
+ end_date: "2024-12-31",
472
+ format: "png",
473
+ },
474
+ },
475
+ });
476
+ expect(result.content[0].type).toBe("image");
477
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
478
+ expect(result.content[0].mimeType).toBe("image/png");
479
+ });
480
+ test("should request SVG and return text content", async () => {
481
+ mockFetch.mockResolvedValueOnce({
482
+ ok: true,
483
+ status: 200,
484
+ text: async () => "<svg>chart</svg>",
485
+ headers: new Headers(),
486
+ });
487
+ const result = await server["handleToolCall"]({
488
+ method: "tools/call",
489
+ params: {
490
+ name: "get_chart_image",
491
+ arguments: {
492
+ realm: "Jobs",
493
+ group_by: "none",
494
+ statistic: "total_cpu_hours",
495
+ start_date: "2024-01-01",
496
+ end_date: "2024-12-31",
497
+ format: "svg",
498
+ },
499
+ },
500
+ });
501
+ expect(result.content[0].type).toBe("text");
502
+ const text = getText(result);
503
+ expect(text).toContain("SVG");
504
+ expect(text).toContain("<svg>chart</svg>");
505
+ });
506
+ });
507
+ });
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { BaseAccessServer, Tool, Resource, CallToolResult } from "@access-mcp/shared";
3
+ export declare class XDMoDMetricsServer extends BaseAccessServer {
4
+ private menuCache;
5
+ private static readonly MENU_CACHE_TTL;
6
+ constructor();
7
+ private getHeaders;
8
+ /**
9
+ * Fetch the XDMoD menu tree (public_user=true). Cached in memory.
10
+ */
11
+ private fetchMenus;
12
+ /**
13
+ * Resolve a filter value to a numeric XDMoD dimension ID.
14
+ * If the value is already numeric, returns it as-is.
15
+ * Otherwise searches the dimension API for a matching entry.
16
+ */
17
+ private resolveFilterId;
18
+ /**
19
+ * Resolve all filter values in a filters object to numeric IDs.
20
+ * Supports both string values and arrays of strings (for multi-value filters).
21
+ */
22
+ private resolveFilters;
23
+ private static readonly PUBLIC_REALMS;
24
+ protected getTools(): Tool[];
25
+ protected getResources(): Resource[];
26
+ protected handleToolCall(request: {
27
+ method: "tools/call";
28
+ params: {
29
+ name: string;
30
+ arguments?: Record<string, unknown>;
31
+ };
32
+ }): Promise<CallToolResult>;
33
+ private getChartData;
34
+ private getChartImage;
35
+ private getChartLink;
36
+ private describeRealms;
37
+ private describeFields;
38
+ private getDimensionValues;
39
+ }