@access-mcp/software-discovery
Version:
ACCESS-CI Software Discovery Service MCP server
645 lines (644 loc) • 28 kB
JavaScript
import { BaseAccessServer, handleApiError } from "@access-mcp/shared";
import axios from "axios";
export class SoftwareDiscoveryServer extends BaseAccessServer {
_sdsClient;
constructor() {
super("access-mcp-software-discovery", "0.6.0", "https://sds-ara-api.access-ci.org");
}
/**
* Normalizes resource IDs to handle legacy XSEDE format and domain variations.
* This provides backward compatibility while the SDS API migrates to ACCESS-CI format.
*
* @param resourceId - The resource ID to normalize
* @returns The normalized resource ID in ACCESS-CI format
*/
normalizeResourceId(resourceId) {
// Convert old XSEDE format to new ACCESS-CI format
if (resourceId.includes(".xsede.org")) {
resourceId = resourceId.replace(".xsede.org", ".access-ci.org");
}
// Convert legacy domain variations
if (resourceId.includes(".illinois.edu")) {
resourceId = resourceId.replace(".illinois.edu", ".access-ci.org");
}
if (resourceId.includes(".edu")) {
resourceId = resourceId.replace(".edu", ".access-ci.org");
}
// Handle specific resource types from compute-resources service
// Convert delta-gpu.ncsa.access-ci.org -> delta.ncsa.access-ci.org
// Convert delta-cpu.ncsa.access-ci.org -> delta.ncsa.access-ci.org
// Convert delta-storage.ncsa.access-ci.org -> delta.ncsa.access-ci.org
resourceId = resourceId.replace(/-(gpu|cpu|storage|compute)\./, '.');
// Handle other common patterns
resourceId = resourceId.replace(/-(login|data|transfer)\./, '.');
// If already in correct format or unknown format, return as-is
return resourceId;
}
get sdsClient() {
if (!this._sdsClient) {
const apiKey = process.env.SDS_API_KEY || process.env.VITE_SDS_API_KEY;
this._sdsClient = axios.create({
baseURL: "https://sds-ara-api.access-ci.org",
timeout: 30000,
headers: {
"Content-Type": "application/json",
"User-Agent": "access-mcp-software-discovery/0.6.0",
...(apiKey ? { "X-API-Key": apiKey } : {}),
},
validateStatus: () => true,
});
}
return this._sdsClient;
}
/**
* Makes a query to the new SDS API v1
*/
async queryApi(params) {
const apiKey = process.env.SDS_API_KEY || process.env.VITE_SDS_API_KEY;
if (!apiKey) {
throw new Error("SDS API key not configured. Set SDS_API_KEY environment variable.");
}
const response = await this.sdsClient.post("/api/v1", params);
if (response.status !== 200) {
throw new Error(`SDS API error: ${response.status} ${response.statusText}`);
}
// Handle the new API response format: { data: [...] }
const responseData = response.data;
if ('data' in responseData && Array.isArray(responseData.data)) {
return responseData.data;
}
return Array.isArray(responseData) ? responseData : [];
}
getTools() {
return [
{
name: "search_software",
description: "Search software packages on ACCESS-CI HPC resources with fuzzy matching. Returns {total, items}.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Software name to search (e.g., 'python', 'tensorflow', 'gromacs'). Fuzzy matching finds partial matches."
},
resource: {
type: "string",
description: "Filter to resource (e.g., 'anvil', 'delta', 'expanse', 'bridges-2')"
},
fuzzy: {
type: "boolean",
description: "Enable fuzzy/partial matching (default: true)",
default: true
},
include_ai_metadata: {
type: "boolean",
description: "Include AI metadata (tags, research area, software type)",
default: true
},
limit: {
type: "number",
description: "Max results (default: 100)",
default: 100
}
},
examples: [
{
name: "Search for Python",
arguments: { query: "python", limit: 10 }
},
{
name: "Find TensorFlow on Anvil",
arguments: { query: "tensorflow", resource: "anvil" }
},
{
name: "Search MPI libraries on Delta",
arguments: { query: "mpi", resource: "delta", limit: 20 }
}
]
}
},
{
name: "list_all_software",
description: "List all available software packages on ACCESS-CI resources. Returns {total, items}.",
inputSchema: {
type: "object",
properties: {
resource: {
type: "string",
description: "Filter to resource (e.g., 'anvil', 'delta'). Omit for all resources."
},
include_ai_metadata: {
type: "boolean",
description: "Include AI metadata (default: false for compact output)",
default: false
},
limit: {
type: "number",
description: "Max results (default: 100)",
default: 100
}
},
examples: [
{
name: "List all software",
arguments: { limit: 50 }
},
{
name: "List software on Delta",
arguments: { resource: "delta", limit: 100 }
}
]
}
},
{
name: "get_software_details",
description: "Get detailed info about a specific software package including versions and availability. Returns {found, details, other_matches}.",
inputSchema: {
type: "object",
properties: {
software_name: {
type: "string",
description: "Software name (e.g., 'tensorflow', 'gromacs', 'python')"
},
resource: {
type: "string",
description: "Filter to specific resource (optional)"
},
fuzzy: {
type: "boolean",
description: "Enable fuzzy matching (default: true)",
default: true
}
},
required: ["software_name"],
examples: [
{
name: "Get TensorFlow details",
arguments: { software_name: "tensorflow" }
},
{
name: "Get GROMACS on Expanse",
arguments: { software_name: "gromacs", resource: "expanse" }
}
]
}
},
{
name: "compare_software_availability",
description: "Compare availability of multiple software packages across resources. Returns {comparison, summary}.",
inputSchema: {
type: "object",
properties: {
software_names: {
type: "array",
items: { type: "string" },
description: "Software packages to compare (e.g., ['tensorflow', 'pytorch'])"
},
resources: {
type: "array",
items: { type: "string" },
description: "Resources to check (optional, compares all if omitted)"
}
},
required: ["software_names"],
examples: [
{
name: "Compare ML frameworks",
arguments: { software_names: ["tensorflow", "pytorch", "cuda"] }
},
{
name: "Check compilers on specific resources",
arguments: {
software_names: ["gcc", "intel", "nvhpc"],
resources: ["anvil", "delta", "expanse"]
}
}
]
}
}
];
}
getResources() {
return [
{
uri: "accessci://software-discovery",
name: "ACCESS-CI Software Discovery Service",
description: "Search and discover software packages available on ACCESS-CI resources",
mimeType: "application/json",
},
{
uri: "accessci://software/categories",
name: "Software Filter Values",
description: "Discover available filter values from actual software data",
mimeType: "application/json",
},
];
}
async handleToolCall(request) {
const { name, arguments: args = {} } = request.params;
try {
switch (name) {
case "search_software":
return await this.searchSoftware(args);
case "list_all_software":
return await this.listAllSoftware(args);
case "get_software_details":
return await this.getSoftwareDetails(args);
case "compare_software_availability":
return await this.compareSoftwareAvailability(args);
default:
return this.errorResponse(`Unknown tool: ${name}`);
}
}
catch (error) {
return this.errorResponse(handleApiError(error));
}
}
async handleResourceRead(request) {
const { uri } = request.params;
switch (uri) {
case "accessci://software-discovery":
return {
contents: [
{
uri,
mimeType: "text/plain",
text: "ACCESS-CI Software Discovery Service - Search and discover software packages available on ACCESS-CI resources. Uses the new SDS API v1 with fuzzy search support.",
},
],
};
case "accessci://software/categories":
const filterValues = await this.discoverFilterValues();
return {
contents: [
{
uri,
mimeType: "application/json",
text: filterValues.content[0].text,
},
],
};
default:
throw new Error(`Unknown resource: ${uri}`);
}
}
/**
* Transform raw API response to enhanced format
*/
transformSoftwareItem(item, includeAiMetadata = true) {
// Extract resource information from the rps object
const resources = [];
const resourceIds = [];
const versionsPerResource = {};
if (item.rps) {
for (const [rpKey, rpInfo] of Object.entries(item.rps)) {
resources.push(rpInfo.rp_name || rpKey);
if (rpInfo.rp_resource_id) {
resourceIds.push(...rpInfo.rp_resource_id);
}
if (rpInfo.software_versions) {
versionsPerResource[rpInfo.rp_name || rpKey] = rpInfo.software_versions;
}
}
}
// Collect all unique versions across resources
const allVersions = new Set();
Object.values(versionsPerResource).forEach(versions => {
versions.split(',').forEach(v => {
const trimmed = v.trim();
if (trimmed)
allVersions.add(trimmed);
});
});
const result = {
name: item.software_name,
description: item.software_description || null,
versions: Array.from(allVersions).sort(),
documentation: item.software_documentation || null,
website: item.software_web_page || null,
usage_link: item.software_use_link || null,
available_on_resources: [...new Set(resources)],
resource_ids: [...new Set(resourceIds)],
versions_by_resource: Object.keys(versionsPerResource).length > 0 ? versionsPerResource : undefined,
};
if (includeAiMetadata) {
result.ai_metadata = {
description: item.ai_description || null,
tags: item.ai_general_tags
? item.ai_general_tags.split(',').map((t) => t.trim()).filter(Boolean)
: [],
research_area: item.ai_research_area || null,
research_discipline: item.ai_research_discipline || null,
research_field: item.ai_research_field || null,
software_type: item.ai_software_type || null,
software_class: item.ai_software_class || null,
core_features: item.ai_core_features || null,
example_use: item.ai_example_use || null,
};
}
return result;
}
async searchSoftware(args) {
const { query, resource, fuzzy = true, include_ai_metadata = true, limit = 100 } = args;
// Build query params
const params = {};
if (query) {
params.software = [query];
if (fuzzy) {
params.fuzz_software = true;
}
}
else {
// Get all software if no query
params.software = ["*"];
}
if (resource) {
const normalizedResource = this.normalizeResourceId(resource);
params.rps = [normalizedResource];
if (fuzzy) {
params.fuzz_rp = true;
}
}
try {
let results = await this.queryApi(params);
// Sort results by match quality when there's a query: exact > starts-with > contains
if (query) {
const queryLower = query.toLowerCase();
results = [...results].sort((a, b) => {
const aName = a.software_name.toLowerCase();
const bName = b.software_name.toLowerCase();
const aExact = aName === queryLower ? 0 : 1;
const bExact = bName === queryLower ? 0 : 1;
if (aExact !== bExact)
return aExact - bExact;
const aStarts = aName.startsWith(queryLower) ? 0 : 1;
const bStarts = bName.startsWith(queryLower) ? 0 : 1;
if (aStarts !== bStarts)
return aStarts - bStarts;
const aContains = aName.includes(queryLower) ? 0 : 1;
const bContains = bName.includes(queryLower) ? 0 : 1;
return aContains - bContains;
});
}
// Apply limit
const limitedResults = results.slice(0, limit);
// Transform results
const transformedResults = limitedResults.map(item => this.transformSoftwareItem(item, include_ai_metadata));
return {
content: [{
type: "text",
text: JSON.stringify({
total: transformedResults.length,
query: query || null,
resource_filter: resource || null,
fuzzy_matching: fuzzy,
items: transformedResults
})
}]
};
}
catch (error) {
return this.errorResponse(error.message);
}
}
async listAllSoftware(args) {
const { resource, include_ai_metadata = false, limit = 100 } = args;
const params = {
software: ["*"]
};
if (resource) {
const normalizedResource = this.normalizeResourceId(resource);
params.rps = [normalizedResource];
params.fuzz_rp = true;
}
try {
const results = await this.queryApi(params);
// Apply limit
const limitedResults = results.slice(0, limit);
// Transform results
const transformedResults = limitedResults.map(item => this.transformSoftwareItem(item, include_ai_metadata));
return {
content: [{
type: "text",
text: JSON.stringify({
total: transformedResults.length,
resource_filter: resource || "all resources",
items: transformedResults
})
}]
};
}
catch (error) {
return this.errorResponse(error.message);
}
}
async getSoftwareDetails(args) {
const { software_name, resource, fuzzy = true } = args;
const params = {
software: [software_name],
fuzz_software: fuzzy
};
if (resource) {
const normalizedResource = this.normalizeResourceId(resource);
params.rps = [normalizedResource];
params.fuzz_rp = true;
}
try {
const results = await this.queryApi(params);
if (results.length === 0) {
return {
content: [{
type: "text",
text: JSON.stringify({
software_name,
found: false,
message: `No software found matching '${software_name}'${resource ? ` on resource '${resource}'` : ''}`,
suggestion: "Try a different search term or enable fuzzy matching"
})
}]
};
}
// Sort results by match quality: exact > starts-with > contains
const queryLower = software_name.toLowerCase();
const sortedResults = [...results].sort((a, b) => {
const aName = a.software_name.toLowerCase();
const bName = b.software_name.toLowerCase();
const aExact = aName === queryLower ? 0 : 1;
const bExact = bName === queryLower ? 0 : 1;
if (aExact !== bExact)
return aExact - bExact;
const aStarts = aName.startsWith(queryLower) ? 0 : 1;
const bStarts = bName.startsWith(queryLower) ? 0 : 1;
if (aStarts !== bStarts)
return aStarts - bStarts;
const aContains = aName.includes(queryLower) ? 0 : 1;
const bContains = bName.includes(queryLower) ? 0 : 1;
return aContains - bContains;
});
// Get the best match with full details
const bestMatch = this.transformSoftwareItem(sortedResults[0], true);
// If there are multiple matches, include them
const otherMatches = sortedResults.slice(1, 5).map(item => ({
name: item.software_name,
resources: item.rps ? Object.values(item.rps).map(rp => rp.rp_name) : []
}));
return {
content: [{
type: "text",
text: JSON.stringify({
software_name,
found: true,
details: bestMatch,
other_matches: otherMatches.length > 0 ? otherMatches : undefined
})
}]
};
}
catch (error) {
return this.errorResponse(error.message);
}
}
async compareSoftwareAvailability(args) {
const { software_names, resources } = args;
try {
// Query for all requested software
const params = {
software: software_names,
fuzz_software: true
};
if (resources && resources.length > 0) {
params.rps = resources.map(r => this.normalizeResourceId(r));
params.fuzz_rp = true;
}
const results = await this.queryApi(params);
// Build availability matrix
const availabilityMap = {};
const allResources = new Set();
for (const item of results) {
const softwareName = item.software_name.toLowerCase();
if (!availabilityMap[softwareName]) {
availabilityMap[softwareName] = new Set();
}
// Extract resources from the rps object
if (item.rps) {
for (const rpInfo of Object.values(item.rps)) {
const rpName = rpInfo.rp_name;
if (rpName) {
availabilityMap[softwareName].add(rpName);
allResources.add(rpName);
}
}
}
}
// Create comparison table
const comparison = [];
for (const softwareName of software_names) {
const softwareLower = softwareName.toLowerCase();
// Priority matching: exact match first, then starts-with, then contains
let foundKey = Object.keys(availabilityMap).find(k => k === softwareLower);
if (!foundKey) {
foundKey = Object.keys(availabilityMap).find(k => k.startsWith(softwareLower));
}
if (!foundKey) {
foundKey = Object.keys(availabilityMap).find(k => k.includes(softwareLower));
}
if (!foundKey) {
foundKey = Object.keys(availabilityMap).find(k => softwareLower.includes(k));
}
comparison.push({
software: softwareName,
found: !!foundKey,
available_on: foundKey ? Array.from(availabilityMap[foundKey]) : [],
resource_count: foundKey ? availabilityMap[foundKey].size : 0
});
}
return {
content: [{
type: "text",
text: JSON.stringify({
requested_software: software_names,
requested_resources: resources || "all",
all_resources_found: Array.from(allResources).sort(),
comparison,
summary: {
total_software_requested: software_names.length,
software_found: comparison.filter(c => c.found).length,
software_not_found: comparison.filter(c => !c.found).map(c => c.software)
}
})
}]
};
}
catch (error) {
return this.errorResponse(error.message);
}
}
async discoverFilterValues() {
try {
// Get a sample of all software
const results = await this.queryApi({
software: ["*"]
});
// Extract unique values
const researchAreas = new Set();
const tags = new Map();
const softwareTypes = new Set();
const resources = new Set();
for (const item of results) {
// Collect research areas
if (item.ai_research_area)
researchAreas.add(item.ai_research_area);
if (item.ai_research_discipline)
researchAreas.add(item.ai_research_discipline);
if (item.ai_research_field)
researchAreas.add(item.ai_research_field);
// Collect and count tags
if (item.ai_general_tags) {
const itemTags = item.ai_general_tags.split(',').map(t => t.trim()).filter(Boolean);
for (const tag of itemTags) {
tags.set(tag, (tags.get(tag) || 0) + 1);
}
}
// Collect software types
if (item.ai_software_type)
softwareTypes.add(item.ai_software_type);
if (item.ai_software_class)
softwareTypes.add(item.ai_software_class);
// Collect resources from the rps object
if (item.rps) {
for (const rpInfo of Object.values(item.rps)) {
if (rpInfo.rp_name)
resources.add(rpInfo.rp_name);
}
}
}
// Sort tags by frequency
const sortedTags = Array.from(tags.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 30)
.map(([tag, count]) => ({ value: tag, count }));
return {
content: [{
type: "text",
text: JSON.stringify({
sample_size: results.length,
discovered_values: {
research_areas: Array.from(researchAreas).sort(),
software_types: Array.from(softwareTypes).sort(),
top_tags: sortedTags,
resources: Array.from(resources).sort()
},
api_info: {
version: "v1",
base_url: "https://sds-ara-api.access-ci.org",
supports_fuzzy_search: true
}
}, null, 2)
}]
};
}
catch (error) {
return this.errorResponse(error.message);
}
}
}