@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.
- package/README.md +113 -0
- package/dist/__tests__/server.integration.test.d.ts +1 -0
- package/dist/__tests__/server.integration.test.js +91 -0
- package/dist/__tests__/server.test.d.ts +1 -0
- package/dist/__tests__/server.test.js +507 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +1029 -0
- package/dist/user-specific.d.ts +18 -0
- package/dist/user-specific.js +58 -0
- package/package.json +46 -0
|
@@ -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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|