@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/dist/index.js ADDED
@@ -0,0 +1,1029 @@
1
+ #!/usr/bin/env node
2
+ import { BaseAccessServer } from "@access-mcp/shared";
3
+ import { createRequire } from "module";
4
+ const require = createRequire(import.meta.url);
5
+ const { version } = require("../package.json");
6
+ // Static realm metadata discovered from the live XDMoD API at xdmod.access-ci.org.
7
+ // Statistics were discovered by requesting get_data with no statistic parameter
8
+ // (returns all available fields in metaData.fields), then querying each individually
9
+ // to get the display name. Last updated: 2026-02-22.
10
+ const REALM_STATISTICS = {
11
+ Accounts: {
12
+ description: "ACCESS user account tracking — accounts associated with allocations and job activity",
13
+ statistics: {
14
+ unique_account_count: "Number of Accounts: Created",
15
+ unique_account_with_jobs_count: "Number of Accounts: Created w/Jobs",
16
+ },
17
+ },
18
+ Allocations: {
19
+ description: "Allocation and project tracking — active allocations, PIs, and resource usage in SUs/ACEs",
20
+ statistics: {
21
+ active_allocation_count: "Number of Projects: Active",
22
+ active_pi_count: "Number of PIs: Active",
23
+ active_resallocation_count: "Number of Allocations: Active",
24
+ allocated_nu: "NUs: Allocated",
25
+ allocated_raw_su: "CPU Core Hours: Allocated",
26
+ allocated_su: "XD SUs: Allocated",
27
+ allocated_ace: "ACCESS Credit Equivalents: Allocated (SU)",
28
+ rate_of_usage: "Allocation Usage Rate (XD SU/Hour)",
29
+ rate_of_usage_ace: "Allocation Usage Rate ACEs (SU/Hour)",
30
+ used_su: "XD SUs: Used",
31
+ used_ace: "ACCESS Credit Equivalents: Used (SU)",
32
+ },
33
+ },
34
+ Cloud: {
35
+ description: "Cloud and virtualized compute environment metrics",
36
+ statistics: {
37
+ cloud_num_sessions_ended: "Number of Sessions Ended",
38
+ cloud_num_sessions_started: "Number of Sessions Started",
39
+ cloud_num_sessions_running: "Number of Sessions Active",
40
+ cloud_wall_time: "Wall Hours: Total",
41
+ cloud_core_time: "CPU Hours: Total",
42
+ cloud_avg_wallduration_hours: "Wall Hours: Per Session",
43
+ cloud_avg_cores_reserved: "Average Cores Reserved Weighted By Wall Hours",
44
+ cloud_avg_memory_reserved: "Average Memory Reserved Weighted By Wall Hours (Bytes)",
45
+ cloud_avg_rv_storage_reserved: "Average Root Volume Storage Reserved Weighted By Wall Hours (Bytes)",
46
+ cloud_core_utilization: "Core Hour Utilization (%)",
47
+ gateway_session_count: "Number of Sessions Ended via Gateway",
48
+ },
49
+ },
50
+ Gateways: {
51
+ description: "Science gateway job metrics — jobs submitted through ACCESS gateways",
52
+ statistics: {
53
+ job_count: "Number of Jobs Ended",
54
+ running_job_count: "Number of Jobs Running",
55
+ started_job_count: "Number of Jobs Started",
56
+ submitted_job_count: "Number of Jobs Submitted",
57
+ total_cpu_hours: "CPU Hours: Total",
58
+ total_node_hours: "Node Hours: Total",
59
+ total_wallduration_hours: "Wall Hours: Total",
60
+ total_waitduration_hours: "Wait Hours: Total",
61
+ avg_cpu_hours: "CPU Hours: Per Job",
62
+ avg_node_hours: "Node Hours: Per Job",
63
+ avg_wallduration_hours: "Wall Hours: Per Job",
64
+ avg_waitduration_hours: "Wait Hours: Per Job",
65
+ avg_processors: "Job Size: Per Job (Core Count)",
66
+ max_processors: "Job Size: Max (Core Count)",
67
+ min_processors: "Job Size: Min (Core Count)",
68
+ normalized_avg_processors: "Job Size: Normalized (% of Total Cores)",
69
+ avg_job_size_weighted_by_cpu_hours: "Job Size: Weighted By CPU Hours (Core Count)",
70
+ avg_job_size_weighted_by_xd_su: "Job Size: Weighted By XD SUs (Core Count)",
71
+ avg_job_size_weighted_by_ace: "Job Size: Weighted By ACEs (Core Count)",
72
+ total_su: "XD SUs Charged: Total",
73
+ avg_su: "XD SUs Charged: Per Job",
74
+ total_nu: "NUs Charged: Total",
75
+ avg_nu: "NUs Charged: Per Job",
76
+ total_ace: "ACCESS Credit Equivalents Charged: Total (SU)",
77
+ avg_ace: "ACCESS Credit Equivalents Charged: Per Job (SU)",
78
+ rate_of_usage: "Allocation Usage Rate (XD SU/Hour)",
79
+ rate_of_usage_ace: "Allocation Usage Rate ACEs (SU/Hour)",
80
+ expansion_factor: "User Expansion Factor",
81
+ utilization: "ACCESS CPU Utilization (%)",
82
+ active_resource_count: "Number of Resources: Active",
83
+ active_institution_count: "Number of Institutions: Active",
84
+ active_gateway_count: "Number of Gateways: Active",
85
+ active_gwuser_count: "Number of Gateway Users: Active",
86
+ },
87
+ },
88
+ Jobs: {
89
+ description: "Job accounting and resource usage metrics from job schedulers",
90
+ statistics: {
91
+ job_count: "Number of Jobs Ended",
92
+ running_job_count: "Number of Jobs Running",
93
+ started_job_count: "Number of Jobs Started",
94
+ submitted_job_count: "Number of Jobs Submitted",
95
+ total_cpu_hours: "CPU Hours: Total",
96
+ total_node_hours: "Node Hours: Total",
97
+ total_wallduration_hours: "Wall Hours: Total",
98
+ total_waitduration_hours: "Wait Hours: Total",
99
+ avg_cpu_hours: "CPU Hours: Per Job",
100
+ avg_node_hours: "Node Hours: Per Job",
101
+ avg_wallduration_hours: "Wall Hours: Per Job",
102
+ avg_waitduration_hours: "Wait Hours: Per Job",
103
+ avg_processors: "Job Size: Per Job (Core Count)",
104
+ max_processors: "Job Size: Max (Core Count)",
105
+ min_processors: "Job Size: Min (Core Count)",
106
+ normalized_avg_processors: "Job Size: Normalized (% of Total Cores)",
107
+ avg_job_size_weighted_by_cpu_hours: "Job Size: Weighted By CPU Hours (Core Count)",
108
+ avg_job_size_weighted_by_xd_su: "Job Size: Weighted By XD SUs (Core Count)",
109
+ avg_job_size_weighted_by_ace: "Job Size: Weighted By ACEs (Core Count)",
110
+ total_su: "XD SUs Charged: Total",
111
+ avg_su: "XD SUs Charged: Per Job",
112
+ total_nu: "NUs Charged: Total",
113
+ avg_nu: "NUs Charged: Per Job",
114
+ total_ace: "ACCESS Credit Equivalents Charged: Total (SU)",
115
+ avg_ace: "ACCESS Credit Equivalents Charged: Per Job (SU)",
116
+ rate_of_usage: "Allocation Usage Rate (XD SU/Hour)",
117
+ rate_of_usage_ace: "Allocation Usage Rate ACEs (SU/Hour)",
118
+ expansion_factor: "User Expansion Factor",
119
+ utilization: "ACCESS CPU Utilization (%)",
120
+ gateway_job_count: "Number of Jobs via Gateway",
121
+ active_person_count: "Number of Users: Active",
122
+ active_pi_count: "Number of PIs: Active",
123
+ active_resource_count: "Number of Resources: Active",
124
+ active_allocation_count: "Number of Allocations: Active",
125
+ active_institution_count: "Number of Institutions: Active",
126
+ },
127
+ },
128
+ Requests: {
129
+ description: "Allocation request/proposal tracking",
130
+ statistics: {
131
+ request_count: "Number of Proposals",
132
+ project_count: "Number of Projects",
133
+ },
134
+ },
135
+ ResourceSpecifications: {
136
+ description: "Resource hardware specifications — CPU/GPU counts, node hours, and capacity metrics",
137
+ statistics: {
138
+ total_cpu_core_hours: "CPU Hours: Total",
139
+ allocated_cpu_core_hours: "CPU Hours: Allocated",
140
+ total_gpu_hours: "GPU Hours: Total",
141
+ allocated_gpu_hours: "GPU Hours: Allocated",
142
+ total_gpu_node_hours: "GPU Node Hours: Total",
143
+ allocated_gpu_node_hours: "GPU Node Hours: Allocated",
144
+ total_cpu_node_hours: "CPU Node Hours: Total",
145
+ allocated_cpu_node_hours: "CPU Node Hours: Allocated",
146
+ total_avg_number_of_cpu_cores: "Average Number of CPU Cores: Total",
147
+ allocated_avg_number_of_cpu_cores: "Average Number of CPU Cores: Allocated",
148
+ total_avg_number_of_gpus: "Average Number of GPUs: Total",
149
+ allocated_avg_number_of_gpus: "Average Number of GPUs: Allocated",
150
+ total_avg_number_of_cpu_nodes: "Average Number of CPU Nodes: Total",
151
+ allocated_avg_number_of_cpu_nodes: "Average Number of CPU Nodes: Allocated",
152
+ total_avg_number_of_gpu_nodes: "Average Number of GPU Nodes: Total",
153
+ allocated_avg_number_of_gpu_nodes: "Average Number of GPU Nodes: Allocated",
154
+ ace_total: "ACCESS Credit Equivalents Available: Total (SU)",
155
+ ace_allocated: "ACCESS Credit Equivalents Available: Allocated (SU)",
156
+ },
157
+ },
158
+ Storage: {
159
+ description: "File system and storage usage metrics (requires authentication — not available for public queries)",
160
+ statistics: {
161
+ user_count: "User Count",
162
+ avg_physical_usage: "Physical Usage (Bytes)",
163
+ avg_logical_usage: "Logical Usage (Bytes)",
164
+ avg_file_count: "File Count",
165
+ avg_hard_threshold: "Quota: Hard Threshold (Bytes)",
166
+ avg_soft_threshold: "Quota: Soft Threshold (Bytes)",
167
+ },
168
+ },
169
+ SUPREMM: {
170
+ description: "Detailed job performance analytics — CPU, GPU, memory, network, and I/O metrics from monitoring",
171
+ statistics: {
172
+ job_count: "Number of Jobs Ended",
173
+ short_job_count: "Number of Short Jobs Ended",
174
+ running_job_count: "Number of Jobs Running",
175
+ started_job_count: "Number of Jobs Started",
176
+ submitted_job_count: "Number of Jobs Submitted",
177
+ wall_time: "CPU Hours: Total",
178
+ wall_time_per_job: "Wall Hours: Per Job",
179
+ wait_time: "Wait Hours: Total",
180
+ wait_time_per_job: "Wait Hours: Per Job",
181
+ requested_wall_time: "Wall Hours: Requested: Total",
182
+ requested_wall_time_per_job: "Wall Hours: Requested: Per Job",
183
+ wall_time_accuracy: "Wall Time Accuracy (%)",
184
+ cpu_time_user: "CPU Hours: User: Total",
185
+ cpu_time_system: "CPU Hours: System: Total",
186
+ cpu_time_idle: "CPU Hours: Idle: Total",
187
+ avg_percent_cpu_user: "Avg CPU %: User: weighted by core-hour",
188
+ avg_percent_cpu_system: "Avg CPU %: System: weighted by core-hour",
189
+ avg_percent_cpu_idle: "Avg CPU %: Idle: weighted by core-hour",
190
+ avg_cpuusercv_per_core: "Avg: CPU User CV: weighted by core-hour",
191
+ avg_cpuuserimb_per_core: "Avg: CPU User Imbalance: weighted by core-hour (%)",
192
+ gpu_time: "GPU Hours: Total",
193
+ avg_percent_gpu_usage: "Avg GPU usage: weighted by GPU hour (GPU %)",
194
+ avg_flops_per_core: "Avg: FLOPS: Per Core weighted by core-hour (ops/s)",
195
+ avg_cpiref_per_core: "Avg: CPI: Per Core weighted by core-hour",
196
+ avg_cpldref_per_core: "Avg: CPLD: Per Core weighted by core-hour",
197
+ avg_memory_per_core: "Avg: Memory: Per Core weighted by core-hour (bytes)",
198
+ avg_total_memory_per_core: "Avg: Total Memory: Per Core weighted by core-hour (bytes)",
199
+ avg_max_memory_per_core: "Avg: Max Memory: weighted by core-hour (%)",
200
+ avg_mem_bw_per_core: "Avg: Memory Bandwidth: Per Core weighted by core-hour (bytes/s)",
201
+ avg_ib_rx_bytes: "Avg: InfiniBand rate: Per Node weighted by node-hour (bytes/s)",
202
+ avg_homogeneity: "Avg: Homogeneity: weighted by node-hour (%)",
203
+ avg_net_eth0_rx: "Avg: eth0 receive rate: Per Node weighted by node-hour (bytes/s)",
204
+ avg_net_eth0_tx: "Avg: eth0 transmit rate: Per Node weighted by node-hour (bytes/s)",
205
+ avg_net_ib0_rx: "Avg: ib0 receive rate: Per Node weighted by node-hour (bytes/s)",
206
+ avg_net_ib0_tx: "Avg: ib0 transmit rate: Per Node weighted by node-hour (bytes/s)",
207
+ avg_netdrv_lustre_rx: "Avg: lustre receive rate: Per Node weighted by node-hour (bytes/s)",
208
+ avg_netdrv_lustre_tx: "Avg: lustre transmit rate: Per Node weighted by node-hour (bytes/s)",
209
+ avg_block_sda_rd_bytes: "Avg: block sda read rate: Per Node weighted by node-hour (bytes/s)",
210
+ avg_block_sda_wr_bytes: "Avg: block sda write rate: Per Node weighted by node-hour (bytes/s)",
211
+ avg_block_sda_rd_ios: "Avg: block sda read ops rate: Per Node weighted by node-hour (ops/s)",
212
+ avg_block_sda_wr_ios: "Avg: block sda write ops rate: Per Node weighted by node-hour (ops/s)",
213
+ avg_netdir_home_write: "Avg: /home write rate: Per Node weighted by node-hour (bytes/s)",
214
+ avg_netdir_scratch_write: "Avg: /scratch write rate: Per Node weighted by node-hour (bytes/s)",
215
+ avg_netdir_work_write: "Avg: /work write rate: Per Node weighted by node-hour (bytes/s)",
216
+ total_su: "XD SUs Charged: Total",
217
+ avg_su: "XD SUs Charged: Per Job",
218
+ total_ace: "ACCESS Credit Equivalents Charged: Total (SU)",
219
+ avg_ace: "ACCESS Credit Equivalents Charged: Per Job (SU)",
220
+ active_pi_count: "Number of PIs: Active",
221
+ active_app_count: "Number of Applications: Active",
222
+ },
223
+ },
224
+ };
225
+ export class XDMoDMetricsServer extends BaseAccessServer {
226
+ menuCache = null;
227
+ static MENU_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
228
+ constructor() {
229
+ super("xdmod", version, "https://xdmod.access-ci.org");
230
+ }
231
+ getHeaders() {
232
+ return {
233
+ "Content-Type": "application/x-www-form-urlencoded",
234
+ };
235
+ }
236
+ /**
237
+ * Fetch the XDMoD menu tree (public_user=true). Cached in memory.
238
+ */
239
+ async fetchMenus() {
240
+ if (this.menuCache && Date.now() - this.menuCache.timestamp < XDMoDMetricsServer.MENU_CACHE_TTL) {
241
+ return this.menuCache.data;
242
+ }
243
+ const response = await fetch(`${this.baseURL}/controllers/user_interface.php`, {
244
+ method: "POST",
245
+ headers: this.getHeaders(),
246
+ body: new URLSearchParams({
247
+ operation: "get_menus",
248
+ public_user: "true",
249
+ node: "category_",
250
+ }),
251
+ });
252
+ if (!response.ok) {
253
+ throw new Error(`Failed to fetch XDMoD menus: HTTP ${response.status}`);
254
+ }
255
+ const json = await response.json();
256
+ const data = json.data ?? json ?? [];
257
+ this.menuCache = { data, timestamp: Date.now() };
258
+ return data;
259
+ }
260
+ /**
261
+ * Resolve a filter value to a numeric XDMoD dimension ID.
262
+ * If the value is already numeric, returns it as-is.
263
+ * Otherwise searches the dimension API for a matching entry.
264
+ */
265
+ async resolveFilterId(realm, dimension, value) {
266
+ // Already numeric — use as-is
267
+ if (/^\d+$/.test(value))
268
+ return value;
269
+ const response = await fetch(`${this.baseURL}/controllers/metric_explorer.php`, {
270
+ method: "POST",
271
+ headers: this.getHeaders(),
272
+ body: new URLSearchParams({
273
+ operation: "get_dimension",
274
+ public_user: "true",
275
+ realm,
276
+ dimension_id: dimension,
277
+ start: "0",
278
+ limit: "200",
279
+ }),
280
+ });
281
+ if (!response.ok)
282
+ return value; // fallback to original
283
+ const json = await response.json();
284
+ const items = json.data ?? [];
285
+ const lower = value.toLowerCase();
286
+ // Normalize: strip hyphens/extra spaces for fuzzy matching (e.g., "Bridges-2" vs "Bridges 2 RM")
287
+ const normalize = (s) => s.toLowerCase().replace(/[-_]/g, " ").replace(/\s+/g, " ").trim();
288
+ const normalized = normalize(value);
289
+ // 1. Exact name match
290
+ const exact = items.find((i) => i.name?.toLowerCase() === lower ||
291
+ i.short_name?.toLowerCase() === lower);
292
+ if (exact?.id)
293
+ return exact.id;
294
+ // 2. All items whose name contains the search term (or vice versa)
295
+ const matches = items.filter((i) => {
296
+ const normName = normalize(i.name ?? "");
297
+ return (normName.includes(normalized) ||
298
+ normalized.includes(normName) ||
299
+ i.name?.toLowerCase().includes(lower) ||
300
+ lower.includes(i.name?.toLowerCase() ?? "___"));
301
+ });
302
+ if (matches.length === 1)
303
+ return matches[0].id ?? value;
304
+ if (matches.length > 1) {
305
+ // Multiple matches — return all IDs so XDMoD includes all
306
+ return matches.map((m) => m.id).filter(Boolean).join(",");
307
+ }
308
+ return value; // no match found, pass through
309
+ }
310
+ /**
311
+ * Resolve all filter values in a filters object to numeric IDs.
312
+ * Supports both string values and arrays of strings (for multi-value filters).
313
+ */
314
+ async resolveFilters(realm, filters) {
315
+ const resolved = {};
316
+ for (const [dimension, value] of Object.entries(filters)) {
317
+ if (Array.isArray(value)) {
318
+ // Multi-value filter: resolve each and join with commas
319
+ const ids = await Promise.all(value.map((v) => this.resolveFilterId(realm, dimension, String(v))));
320
+ resolved[dimension] = ids.join(",");
321
+ }
322
+ else {
323
+ resolved[dimension] = await this.resolveFilterId(realm, dimension, String(value));
324
+ }
325
+ }
326
+ return resolved;
327
+ }
328
+ // Realms available for public (unauthenticated) queries.
329
+ // Storage requires authentication and is excluded.
330
+ static PUBLIC_REALMS = Object.keys(REALM_STATISTICS)
331
+ .filter((r) => r !== "Storage")
332
+ .sort();
333
+ getTools() {
334
+ const tools = [
335
+ {
336
+ name: "get_chart_data",
337
+ description: "Fetch numeric data from XDMoD. Filters accept names (auto-resolved to IDs). " +
338
+ "Stats by realm — Jobs: job_count, total_cpu_hours, total_su, active_person_count; " +
339
+ "Allocations: allocated_ace, used_ace, active_allocation_count; Accounts: unique_account_count; " +
340
+ "Gateways: active_gateway_count, job_count; SUPREMM: gpu_time, short_job_count, avg_flops_per_core, wall_time_accuracy; " +
341
+ "Cloud: cloud_num_sessions_started; ResourceSpecifications: total_avg_number_of_gpus; Requests: project_count.",
342
+ inputSchema: {
343
+ type: "object",
344
+ properties: {
345
+ realm: {
346
+ type: "string",
347
+ description: 'Data realm. Jobs=job counts/CPU hours/SUs/active users; ' +
348
+ 'Allocations=ACEs/SUs allocated+used/active allocations/PIs; Accounts=user account counts; ' +
349
+ 'Gateways=gateway job counts/active gateways; SUPREMM=GPU time/CPU perf/FLOPS/short jobs/memory; ' +
350
+ 'Cloud=VM sessions/core hours; ResourceSpecifications=GPU+CPU node counts/capacity; ' +
351
+ 'Requests=proposals/projects submitted.',
352
+ enum: XDMoDMetricsServer.PUBLIC_REALMS,
353
+ },
354
+ group_by: {
355
+ type: "string",
356
+ description: 'Dimension to group by. Use "none" for overall totals, or a dimension like "resource", "person", "pi", "institution", "gateway", "fieldofscience". Call describe_fields to see all dimensions for a realm.',
357
+ },
358
+ statistic: {
359
+ type: "string",
360
+ description: 'Exact statistic ID — must match the realm. Use describe_fields to discover all options. ' +
361
+ 'Jobs: job_count, total_cpu_hours, total_su, active_person_count, gateway_job_count. ' +
362
+ 'Allocations: allocated_ace, used_ace, active_allocation_count, active_pi_count. ' +
363
+ 'SUPREMM: gpu_time, short_job_count, wall_time_accuracy, avg_flops_per_core, avg_percent_gpu_usage. ' +
364
+ 'Requests: project_count, request_count. Accounts: unique_account_count.',
365
+ },
366
+ start_date: {
367
+ type: "string",
368
+ description: "Start date in YYYY-MM-DD format",
369
+ format: "date",
370
+ },
371
+ end_date: {
372
+ type: "string",
373
+ description: "End date in YYYY-MM-DD format",
374
+ format: "date",
375
+ },
376
+ dataset_type: {
377
+ type: "string",
378
+ description: 'Dataset type (default: "timeseries")',
379
+ enum: ["timeseries", "aggregate"],
380
+ default: "timeseries",
381
+ },
382
+ display_type: {
383
+ type: "string",
384
+ description: 'Display type (default: "line")',
385
+ enum: ["line", "bar", "pie", "scatter"],
386
+ default: "line",
387
+ },
388
+ combine_type: {
389
+ type: "string",
390
+ description: 'How to combine data (default: "side")',
391
+ enum: ["side", "stack", "percent"],
392
+ default: "side",
393
+ },
394
+ limit: {
395
+ type: "number",
396
+ description: "Maximum number of data series to return (default: 10)",
397
+ default: 10,
398
+ },
399
+ offset: {
400
+ type: "number",
401
+ description: "Offset for pagination (default: 0)",
402
+ default: 0,
403
+ },
404
+ log_scale: {
405
+ type: "string",
406
+ description: 'Use logarithmic scale (default: "n")',
407
+ enum: ["y", "n"],
408
+ default: "n",
409
+ },
410
+ filters: {
411
+ type: "object",
412
+ description: 'Filter by dimension. Keys are dimension names (e.g., "resource", "pi", "fieldofscience"). ' +
413
+ 'Values can be names (e.g., {"resource": "Delta"}) or numeric IDs — names are auto-resolved.',
414
+ additionalProperties: {
415
+ type: "string",
416
+ },
417
+ },
418
+ },
419
+ required: ["realm", "group_by", "statistic", "start_date", "end_date"],
420
+ },
421
+ },
422
+ {
423
+ name: "get_chart_image",
424
+ description: "Get chart image (SVG, PNG, or PDF) for a specific statistic. Use PNG format for direct display in Claude Desktop.",
425
+ inputSchema: {
426
+ type: "object",
427
+ properties: {
428
+ realm: {
429
+ type: "string",
430
+ description: 'The data realm. Use describe_realms to see all.',
431
+ enum: XDMoDMetricsServer.PUBLIC_REALMS,
432
+ },
433
+ group_by: {
434
+ type: "string",
435
+ description: 'Dimension to group by. Use describe_fields for options.',
436
+ },
437
+ statistic: {
438
+ type: "string",
439
+ description: 'The statistic. Use describe_fields for available statistics per realm.',
440
+ },
441
+ start_date: {
442
+ type: "string",
443
+ description: "Start date in YYYY-MM-DD format",
444
+ format: "date",
445
+ },
446
+ end_date: {
447
+ type: "string",
448
+ description: "End date in YYYY-MM-DD format",
449
+ format: "date",
450
+ },
451
+ format: {
452
+ type: "string",
453
+ description: "Image format (svg, png, pdf)",
454
+ enum: ["svg", "png", "pdf"],
455
+ default: "svg",
456
+ },
457
+ width: {
458
+ type: "number",
459
+ description: "Image width in pixels",
460
+ default: 916,
461
+ },
462
+ height: {
463
+ type: "number",
464
+ description: "Image height in pixels",
465
+ default: 484,
466
+ },
467
+ dataset_type: {
468
+ type: "string",
469
+ description: 'Dataset type (default: "timeseries")',
470
+ enum: ["timeseries", "aggregate"],
471
+ default: "timeseries",
472
+ },
473
+ display_type: {
474
+ type: "string",
475
+ description: 'Display type (default: "line")',
476
+ enum: ["line", "bar", "pie", "scatter"],
477
+ default: "line",
478
+ },
479
+ combine_type: {
480
+ type: "string",
481
+ description: 'How to combine data (default: "side")',
482
+ enum: ["side", "stack", "percent"],
483
+ default: "side",
484
+ },
485
+ limit: {
486
+ type: "number",
487
+ description: "Maximum number of data series to return (default: 10)",
488
+ default: 10,
489
+ },
490
+ offset: {
491
+ type: "number",
492
+ description: "Offset for pagination (default: 0)",
493
+ default: 0,
494
+ },
495
+ log_scale: {
496
+ type: "string",
497
+ description: 'Use logarithmic scale (default: "n")',
498
+ enum: ["y", "n"],
499
+ default: "n",
500
+ },
501
+ filters: {
502
+ type: "object",
503
+ description: 'Filter by dimension. Keys are dimension names (e.g., "resource", "pi", "fieldofscience"). ' +
504
+ 'Values can be names (e.g., "Delta") or numeric IDs — names are auto-resolved.',
505
+ additionalProperties: {
506
+ type: "string",
507
+ },
508
+ },
509
+ },
510
+ required: ["realm", "group_by", "statistic", "start_date", "end_date"],
511
+ },
512
+ },
513
+ {
514
+ name: "get_chart_link",
515
+ description: "Generate a direct URL to view an interactive chart in the XDMoD web portal. Use this when users want to explore data interactively, apply additional filters, or share charts with collaborators. The web interface provides more filtering options than the API.",
516
+ inputSchema: {
517
+ type: "object",
518
+ properties: {
519
+ realm: {
520
+ type: "string",
521
+ description: 'The data realm. Use describe_realms to see all.',
522
+ enum: XDMoDMetricsServer.PUBLIC_REALMS,
523
+ },
524
+ group_by: {
525
+ type: "string",
526
+ description: 'Dimension to group by. Use describe_fields for options.',
527
+ },
528
+ statistic: {
529
+ type: "string",
530
+ description: 'The statistic. Use describe_fields for available statistics per realm.',
531
+ },
532
+ },
533
+ required: ["realm", "group_by", "statistic"],
534
+ },
535
+ },
536
+ {
537
+ name: "describe_realms",
538
+ description: "List all available XDMoD data realms (Jobs, SUPREMM, Cloud, Storage, etc.) with their available dimensions and statistics. Use this when you need to know what data categories exist or where to find specific metrics (e.g., GPU metrics are in the SUPREMM realm). No authentication required.",
539
+ inputSchema: {
540
+ type: "object",
541
+ properties: {},
542
+ required: [],
543
+ },
544
+ },
545
+ {
546
+ name: "describe_fields",
547
+ description: "List all dimensions and statistics for a realm. Use this only when you need to discover a statistic ID not listed in get_chart_data's description. Returns group_by dimensions and statistic IDs with labels.",
548
+ inputSchema: {
549
+ type: "object",
550
+ properties: {
551
+ realm: {
552
+ type: "string",
553
+ description: 'XDMoD realm to describe.',
554
+ enum: XDMoDMetricsServer.PUBLIC_REALMS,
555
+ default: "Jobs",
556
+ },
557
+ },
558
+ required: ["realm"],
559
+ },
560
+ },
561
+ {
562
+ name: "get_dimension_values",
563
+ description: "Get the list of filter values for a dimension in a realm. For example, get all available resources, all institutions, or all fields of science. Use this to discover valid filter values before calling get_chart_data. No authentication required.",
564
+ inputSchema: {
565
+ type: "object",
566
+ properties: {
567
+ realm: {
568
+ type: "string",
569
+ description: 'XDMoD realm (e.g., "Jobs", "SUPREMM")',
570
+ },
571
+ dimension: {
572
+ type: "string",
573
+ description: 'Dimension to get values for (e.g., "resource", "person", "institution", "fieldofscience", "jobsize", "queue")',
574
+ },
575
+ limit: {
576
+ type: "number",
577
+ description: "Maximum number of values to return (default: 200)",
578
+ default: 200,
579
+ },
580
+ },
581
+ required: ["realm", "dimension"],
582
+ },
583
+ },
584
+ ];
585
+ return tools;
586
+ }
587
+ getResources() {
588
+ return [];
589
+ }
590
+ async handleToolCall(request) {
591
+ const { name, arguments: args = {} } = request.params;
592
+ // console.log(`[XDMoD] Tool called: ${name}`, args);
593
+ switch (name) {
594
+ case "get_chart_data":
595
+ return await this.getChartData({
596
+ realm: args.realm,
597
+ group_by: args.group_by,
598
+ statistic: args.statistic,
599
+ start_date: args.start_date,
600
+ end_date: args.end_date,
601
+ dataset_type: args.dataset_type || "timeseries",
602
+ display_type: args.display_type || "line",
603
+ combine_type: args.combine_type || "side",
604
+ limit: args.limit || 10,
605
+ offset: args.offset || 0,
606
+ log_scale: args.log_scale || "n",
607
+ filters: args.filters,
608
+ });
609
+ case "get_chart_image":
610
+ return await this.getChartImage({
611
+ realm: args.realm,
612
+ group_by: args.group_by,
613
+ statistic: args.statistic,
614
+ start_date: args.start_date,
615
+ end_date: args.end_date,
616
+ format: args.format || "svg",
617
+ width: args.width || 916,
618
+ height: args.height || 484,
619
+ dataset_type: args.dataset_type || "timeseries",
620
+ display_type: args.display_type || "line",
621
+ combine_type: args.combine_type || "side",
622
+ limit: args.limit || 10,
623
+ offset: args.offset || 0,
624
+ log_scale: args.log_scale || "n",
625
+ filters: args.filters,
626
+ });
627
+ case "get_chart_link":
628
+ return await this.getChartLink(args.realm, args.group_by, args.statistic);
629
+ case "describe_realms":
630
+ return await this.describeRealms();
631
+ case "describe_fields":
632
+ return await this.describeFields(args.realm);
633
+ case "get_dimension_values":
634
+ return await this.getDimensionValues(args.realm, args.dimension, args.limit || 200);
635
+ default:
636
+ throw new Error(`Unknown tool: ${name}`);
637
+ }
638
+ }
639
+ async getChartData(params) {
640
+ try {
641
+ // Resolve text filter values to numeric IDs
642
+ const resolvedFilters = params.filters
643
+ ? await this.resolveFilters(params.realm, params.filters)
644
+ : undefined;
645
+ const urlParams = new URLSearchParams({
646
+ operation: "get_charts",
647
+ public_user: "true",
648
+ dataset_type: params.dataset_type,
649
+ format: "hc_jsonstore",
650
+ width: "916",
651
+ height: "484",
652
+ realm: params.realm,
653
+ group_by: params.group_by,
654
+ statistic: params.statistic,
655
+ start_date: params.start_date,
656
+ end_date: params.end_date,
657
+ });
658
+ if (params.display_type)
659
+ urlParams.append("display_type", params.display_type);
660
+ if (params.combine_type)
661
+ urlParams.append("combine_type", params.combine_type);
662
+ if (params.limit !== undefined)
663
+ urlParams.append("limit", params.limit.toString());
664
+ if (params.offset !== undefined)
665
+ urlParams.append("offset", params.offset.toString());
666
+ if (params.log_scale)
667
+ urlParams.append("log_scale", params.log_scale);
668
+ if (resolvedFilters) {
669
+ for (const [key, value] of Object.entries(resolvedFilters)) {
670
+ urlParams.append(`${key}_filter`, value);
671
+ }
672
+ }
673
+ const response = await fetch(`${this.baseURL}/controllers/user_interface.php`, {
674
+ method: "POST",
675
+ headers: this.getHeaders(),
676
+ body: urlParams,
677
+ });
678
+ const data = await response.json();
679
+ // Check for XDMoD error responses (e.g., invalid statistic name)
680
+ if (!response.ok || data.success === false) {
681
+ const xdmodMsg = data.message || `HTTP ${response.status}`;
682
+ // Provide actionable guidance
683
+ const ref = REALM_STATISTICS[params.realm];
684
+ let hint = "";
685
+ if (xdmodMsg.includes("No Statistic found") && ref) {
686
+ const validStats = Object.keys(ref.statistics).join(", ");
687
+ hint = `\n\nValid statistics for ${params.realm}: ${validStats}`;
688
+ }
689
+ throw new Error(`XDMoD error: ${xdmodMsg}${hint}`);
690
+ }
691
+ let resultText = `Chart Data for ${params.statistic} (${params.realm}):\n\n`;
692
+ if (data.data && data.data.length > 0) {
693
+ const chartInfo = data.data[0];
694
+ if (chartInfo.group_description) {
695
+ resultText += `**Group Description:**\n${chartInfo.group_description}\n\n`;
696
+ }
697
+ if (chartInfo.description) {
698
+ resultText += `**Chart Description:**\n${chartInfo.description}\n\n`;
699
+ }
700
+ if (chartInfo.chart_title) {
701
+ resultText += `**Chart Title:** ${chartInfo.chart_title}\n\n`;
702
+ }
703
+ resultText += `**Raw Data:**\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\``;
704
+ }
705
+ else {
706
+ resultText += "No data available for the specified parameters.";
707
+ }
708
+ return {
709
+ content: [
710
+ {
711
+ type: "text",
712
+ text: resultText,
713
+ },
714
+ ],
715
+ };
716
+ }
717
+ catch (error) {
718
+ throw new Error(`Failed to fetch chart data: ${error instanceof Error ? error.message : String(error)}`);
719
+ }
720
+ }
721
+ async getChartImage(params) {
722
+ try {
723
+ // Resolve text filter values to numeric IDs
724
+ const resolvedFilters = params.filters
725
+ ? await this.resolveFilters(params.realm, params.filters)
726
+ : undefined;
727
+ const urlParams = new URLSearchParams({
728
+ operation: "get_charts",
729
+ public_user: "true",
730
+ dataset_type: params.dataset_type,
731
+ format: params.format,
732
+ width: params.width.toString(),
733
+ height: params.height.toString(),
734
+ realm: params.realm,
735
+ group_by: params.group_by,
736
+ statistic: params.statistic,
737
+ start_date: params.start_date,
738
+ end_date: params.end_date,
739
+ });
740
+ if (params.display_type)
741
+ urlParams.append("display_type", params.display_type);
742
+ if (params.combine_type)
743
+ urlParams.append("combine_type", params.combine_type);
744
+ if (params.limit !== undefined)
745
+ urlParams.append("limit", params.limit.toString());
746
+ if (params.offset !== undefined)
747
+ urlParams.append("offset", params.offset.toString());
748
+ if (params.log_scale)
749
+ urlParams.append("log_scale", params.log_scale);
750
+ if (resolvedFilters) {
751
+ for (const [key, value] of Object.entries(resolvedFilters)) {
752
+ urlParams.append(`${key}_filter`, value);
753
+ }
754
+ }
755
+ const response = await fetch(`${this.baseURL}/controllers/user_interface.php`, {
756
+ method: "POST",
757
+ headers: this.getHeaders(),
758
+ body: urlParams,
759
+ });
760
+ if (!response.ok) {
761
+ // Try to parse error message from XDMoD
762
+ try {
763
+ const errorData = await response.json();
764
+ const ref = REALM_STATISTICS[params.realm];
765
+ let hint = "";
766
+ if (errorData.message?.includes("No Statistic found") && ref) {
767
+ hint = `\n\nValid statistics for ${params.realm}: ${Object.keys(ref.statistics).join(", ")}`;
768
+ }
769
+ throw new Error(`XDMoD error: ${errorData.message || response.statusText}${hint}`);
770
+ }
771
+ catch (parseErr) {
772
+ if (parseErr instanceof Error && parseErr.message.startsWith("XDMoD error"))
773
+ throw parseErr;
774
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
775
+ }
776
+ }
777
+ if (params.format === "png") {
778
+ // For PNG, get binary data and convert to base64
779
+ const imageBuffer = await response.arrayBuffer();
780
+ const base64Data = Buffer.from(imageBuffer).toString("base64");
781
+ // Return with MCP-compliant image format that was working
782
+ return {
783
+ content: [
784
+ {
785
+ type: "image",
786
+ data: base64Data,
787
+ mimeType: "image/png",
788
+ },
789
+ {
790
+ type: "text",
791
+ text: `\nChart Details:\n` +
792
+ `- Statistic: ${params.statistic}\n` +
793
+ `- Realm: ${params.realm}\n` +
794
+ `- Group By: ${params.group_by}\n` +
795
+ `- Date Range: ${params.start_date} to ${params.end_date}\n` +
796
+ `- Size: ${params.width}x${params.height} pixels`,
797
+ },
798
+ ],
799
+ };
800
+ }
801
+ else {
802
+ // For SVG and other text formats
803
+ const imageData = await response.text();
804
+ if (params.format === "svg") {
805
+ // For SVG, provide helpful message about using PNG instead
806
+ return {
807
+ content: [
808
+ {
809
+ type: "text",
810
+ text: `SVG Chart for ${params.statistic} (${params.realm})\n\n` +
811
+ `⚠️ SVG format doesn't display directly in Claude Desktop.\n\n` +
812
+ `**Recommended:** Use PNG format for direct image display:\n` +
813
+ `\`\`\`\n` +
814
+ `format: "png"\n` +
815
+ `\`\`\`\n\n` +
816
+ `**Chart Details:**\n` +
817
+ `- Statistic: ${params.statistic}\n` +
818
+ `- Realm: ${params.realm}\n` +
819
+ `- Group By: ${params.group_by}\n` +
820
+ `- Date Range: ${params.start_date} to ${params.end_date}\n` +
821
+ `- Size: ${params.width}x${params.height} pixels\n\n` +
822
+ `**To view this SVG chart:**\n` +
823
+ `1. Copy the SVG code below\n` +
824
+ `2. Save it to a .svg file and open in your browser\n\n` +
825
+ `\`\`\`svg\n${imageData}\n\`\`\``,
826
+ },
827
+ ],
828
+ };
829
+ }
830
+ else {
831
+ // For PDF and other formats, return as text
832
+ return {
833
+ content: [
834
+ {
835
+ type: "text",
836
+ text: `Chart Image (${params.format.toUpperCase()}) for ${params.statistic}:\n\n` +
837
+ `**Parameters:** Realm: ${params.realm}, Group By: ${params.group_by}, ` +
838
+ `Date Range: ${params.start_date} to ${params.end_date}\n\n` +
839
+ `**To view this chart:**\n` +
840
+ `1. Copy the ${params.format.toUpperCase()} data below\n` +
841
+ `2. Save it to a file with .${params.format} extension\n` +
842
+ `3. Open the file in your browser or image viewer\n\n` +
843
+ `**${params.format.toUpperCase()} Data:**\n\`\`\`${params.format}\n${imageData}\n\`\`\``,
844
+ },
845
+ ],
846
+ };
847
+ }
848
+ }
849
+ }
850
+ catch (error) {
851
+ throw new Error(`Failed to fetch chart image: ${error instanceof Error ? error.message : String(error)}`);
852
+ }
853
+ }
854
+ async getChartLink(realm, groupBy, statistic) {
855
+ // Construct the URL parameters for XDMoD portal
856
+ const urlParams = new URLSearchParams({
857
+ node: "statistic",
858
+ realm: realm,
859
+ group_by: groupBy,
860
+ statistic: statistic,
861
+ });
862
+ const chartUrl = `https://xdmod.access-ci.org/index.php#tg_usage?${urlParams.toString()}`;
863
+ const responseText = `Direct link to view chart in XDMoD portal:\n\n${chartUrl}\n\n` +
864
+ `**Chart Parameters:**\n` +
865
+ `- Realm: ${realm}\n` +
866
+ `- Group By: ${groupBy}\n` +
867
+ `- Statistic: ${statistic}\n\n` +
868
+ `You can use this URL to view the interactive chart directly in the XDMoD web interface. ` +
869
+ `Use the portal's filtering options to narrow down to specific resources, users, or other criteria.`;
870
+ return {
871
+ content: [
872
+ {
873
+ type: "text",
874
+ text: responseText,
875
+ },
876
+ ],
877
+ };
878
+ }
879
+ // --- Discovery tools (public, no auth required) ---
880
+ async describeRealms() {
881
+ try {
882
+ const menus = await this.fetchMenus();
883
+ // Extract unique realm names from menu entries
884
+ const liveRealms = new Set();
885
+ for (const entry of menus) {
886
+ if (entry.realm) {
887
+ liveRealms.add(entry.realm);
888
+ }
889
+ }
890
+ let text = "**Available XDMoD Realms**\n\n";
891
+ // Merge live data with static reference
892
+ const allRealms = new Set([...liveRealms, ...Object.keys(REALM_STATISTICS)]);
893
+ for (const realm of [...allRealms].sort()) {
894
+ const ref = REALM_STATISTICS[realm];
895
+ const isLive = liveRealms.has(realm);
896
+ text += `### ${realm}`;
897
+ if (!isLive)
898
+ text += " (reference only)";
899
+ text += "\n";
900
+ if (ref) {
901
+ text += `${ref.description}\n`;
902
+ // Count dimensions from live API menu entries
903
+ const dims = menus.filter((e) => e.realm === realm);
904
+ if (dims.length > 0) {
905
+ text += `- **Dimensions:** ${dims.length} available\n`;
906
+ }
907
+ text += `- **Statistics:** ${Object.keys(ref.statistics).length} available\n`;
908
+ }
909
+ else {
910
+ // Count dimensions from menu entries
911
+ const dims = menus.filter((e) => e.realm === realm);
912
+ text += `- **Dimensions from API:** ${dims.length} entries\n`;
913
+ }
914
+ text += "\n";
915
+ }
916
+ text += "Use `describe_fields` with a specific realm for full details.\n";
917
+ return { content: [{ type: "text", text }] };
918
+ }
919
+ catch (error) {
920
+ throw new Error(`Failed to describe realms: ${error instanceof Error ? error.message : String(error)}`);
921
+ }
922
+ }
923
+ async describeFields(realm) {
924
+ try {
925
+ const menus = await this.fetchMenus();
926
+ // Filter menu entries for this realm to get dimensions
927
+ const realmEntries = menus.filter((e) => e.realm === realm);
928
+ const ref = REALM_STATISTICS[realm];
929
+ if (realmEntries.length === 0 && !ref) {
930
+ const allRealms = new Set(menus.map((e) => e.realm).filter(Boolean));
931
+ return {
932
+ content: [
933
+ {
934
+ type: "text",
935
+ text: `Realm "${realm}" not found. Available realms: ${[...allRealms].sort().join(", ")}`,
936
+ },
937
+ ],
938
+ };
939
+ }
940
+ let text = `**Fields for Realm: ${realm}**\n\n`;
941
+ // Dimensions from live API
942
+ text += "**Dimensions (group_by values):**\n";
943
+ if (realmEntries.length > 0) {
944
+ const seen = new Set();
945
+ for (const entry of realmEntries) {
946
+ const groupBy = entry.group_by || entry.id || "";
947
+ if (groupBy && !seen.has(groupBy)) {
948
+ seen.add(groupBy);
949
+ text += `- \`${groupBy}\``;
950
+ if (entry.text)
951
+ text += ` — ${entry.text}`;
952
+ text += "\n";
953
+ }
954
+ }
955
+ }
956
+ else {
957
+ text += "No dimension data available from API.\n";
958
+ }
959
+ // Statistics from static reference
960
+ text += "\n**Statistics:**\n";
961
+ if (ref) {
962
+ for (const [id, label] of Object.entries(ref.statistics)) {
963
+ text += `- \`${id}\` — ${label}\n`;
964
+ }
965
+ }
966
+ else {
967
+ text += "No static statistics reference available for this realm.\n";
968
+ text += "Try using `get_chart_data` with common statistics like `total_cpu_hours` or `job_count`.\n";
969
+ }
970
+ text += `\nUse \`get_dimension_values\` to see available filter values for any dimension.\n`;
971
+ return { content: [{ type: "text", text }] };
972
+ }
973
+ catch (error) {
974
+ throw new Error(`Failed to describe fields: ${error instanceof Error ? error.message : String(error)}`);
975
+ }
976
+ }
977
+ async getDimensionValues(realm, dimension, limit) {
978
+ try {
979
+ const response = await fetch(`${this.baseURL}/controllers/metric_explorer.php`, {
980
+ method: "POST",
981
+ headers: this.getHeaders(),
982
+ body: new URLSearchParams({
983
+ operation: "get_dimension",
984
+ public_user: "true",
985
+ realm: realm,
986
+ dimension_id: dimension,
987
+ start: "0",
988
+ limit: limit.toString(),
989
+ }),
990
+ });
991
+ if (!response.ok) {
992
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
993
+ }
994
+ const json = await response.json();
995
+ const totalCount = json.totalCount ?? 0;
996
+ const data = json.data ?? [];
997
+ let text = `**${dimension} values in ${realm}** (${totalCount} total)\n\n`;
998
+ if (data.length === 0) {
999
+ text += "No values found. Check that the realm and dimension are valid.\n";
1000
+ }
1001
+ else {
1002
+ for (const item of data) {
1003
+ const name = item.name || item.short_name || item.id || "unknown";
1004
+ text += `- ${name}`;
1005
+ if (item.id && item.id !== name)
1006
+ text += ` (id: ${item.id})`;
1007
+ text += "\n";
1008
+ }
1009
+ if (totalCount > data.length) {
1010
+ text += `\n... and ${totalCount - data.length} more. Increase \`limit\` to see more.\n`;
1011
+ }
1012
+ }
1013
+ return { content: [{ type: "text", text }] };
1014
+ }
1015
+ catch (error) {
1016
+ throw new Error(`Failed to get dimension values: ${error instanceof Error ? error.message : String(error)}`);
1017
+ }
1018
+ }
1019
+ }
1020
+ // Start the server
1021
+ async function main() {
1022
+ const server = new XDMoDMetricsServer();
1023
+ const port = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
1024
+ await server.start(port ? { httpPort: port } : undefined);
1025
+ }
1026
+ main().catch(() => {
1027
+ // Log errors to a file instead of stderr to avoid interfering with JSON-RPC
1028
+ process.exit(1);
1029
+ });