@access-mcp/software-discovery
Version:
ACCESS-CI Software Discovery Service MCP server
718 lines (717 loc) • 31.7 kB
JavaScript
import { describe, it, expect, beforeEach, vi } from "vitest";
import { SoftwareDiscoveryServer } from "./server.js";
describe("SoftwareDiscoveryServer", () => {
let server;
let mockSdsClient;
// Mock data using the new API response format with nested rps object
const mockSoftwareWithAI = {
data: [
{
software_name: "TensorFlow",
software_description: "Machine learning framework",
software_web_page: "https://tensorflow.org",
software_documentation: "https://tensorflow.org/docs",
rps: {
"delta.ncsa.access-ci.org": {
rp_name: "delta",
rp_resource_id: ["delta-gpu.ncsa.access-ci.org", "delta-cpu.ncsa.access-ci.org"],
software_versions: "2.10,2.11,2.12",
rp_software_documentation: "https://docs.ncsa.edu"
},
"anvil.purdue.access-ci.org": {
rp_name: "anvil",
rp_resource_id: ["anvil.purdue.access-ci.org"],
software_versions: "2.11",
rp_software_documentation: "https://www.rcac.purdue.edu"
}
},
ai_description: "Deep learning framework for neural networks",
ai_general_tags: "machine-learning, deep-learning, gpu, python",
ai_research_area: "Computer & Information Sciences",
ai_research_discipline: "Artificial Intelligence & Intelligent Systems",
ai_research_field: "Computer & Information Sciences",
ai_software_type: "Machine Learning Framework",
ai_software_class: "Library",
ai_core_features: "Flexible architecture for machine learning",
ai_example_use: "Building neural networks for image classification",
},
{
software_name: "GROMACS",
software_description: "Molecular dynamics package",
software_web_page: "https://www.gromacs.org",
rps: {
"anvil.purdue.access-ci.org": {
rp_name: "anvil",
rp_resource_id: ["anvil.purdue.access-ci.org"],
software_versions: "2022.3,2023.1",
rp_software_documentation: ""
},
"expanse.sdsc.access-ci.org": {
rp_name: "expanse",
rp_resource_id: ["expanse.sdsc.access-ci.org"],
software_versions: "2023.1",
rp_software_documentation: ""
}
},
ai_description: "Molecular dynamics simulation software",
ai_general_tags: "molecular-dynamics, chemistry, physics, mpi",
ai_research_area: "Chemistry",
ai_research_discipline: "Biophysics",
ai_research_field: "Chemistry",
ai_software_type: "Simulation Software",
ai_software_class: "Application",
ai_core_features: "Efficient molecular dynamics algorithms",
ai_example_use: "Protein folding simulations",
},
{
software_name: "ParaView",
software_description: "Data visualization application",
software_web_page: "https://www.paraview.org",
rps: {
"bridges2.psc.access-ci.org": {
rp_name: "bridges2",
rp_resource_id: ["bridges2.psc.access-ci.org"],
software_versions: "5.10,5.11",
rp_software_documentation: ""
}
},
ai_description: "Scientific visualization and analysis tool",
ai_general_tags: "visualization, data-analysis, parallel, graphics",
ai_research_area: "Computer & Information Sciences",
ai_research_discipline: "Visualization & Graphics",
ai_research_field: "Computer & Information Sciences",
ai_software_type: "Visualization Tool",
ai_software_class: "Application",
ai_core_features: "Parallel data visualization and analysis",
ai_example_use: "Visualizing computational fluid dynamics results",
},
]
};
beforeEach(() => {
server = new SoftwareDiscoveryServer();
mockSdsClient = { post: vi.fn(), get: vi.fn() };
Object.defineProperty(server, "sdsClient", {
get: () => mockSdsClient,
configurable: true,
});
// Set a mock API key for tests
process.env.SDS_API_KEY = "test-api-key";
});
describe("search_software", () => {
it("should search software with query", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "tensorflow",
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["tensorflow"],
fuzz_software: true,
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.total).toBe(3);
expect(responseData.query).toBe("tensorflow");
expect(responseData.fuzzy_matching).toBe(true);
expect(responseData.items).toBeDefined();
});
it("should include AI metadata by default", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "tensorflow",
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.items[0].ai_metadata).toBeDefined();
expect(responseData.items[0].ai_metadata.tags).toContain("machine-learning");
expect(responseData.items[0].ai_metadata.research_area).toBe("Computer & Information Sciences");
expect(responseData.items[0].ai_metadata.software_class).toBe("Library");
});
it("should extract resources from rps object", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "tensorflow",
},
},
});
const responseData = JSON.parse(result.content[0].text);
const tensorflow = responseData.items[0];
expect(tensorflow.available_on_resources).toContain("delta");
expect(tensorflow.available_on_resources).toContain("anvil");
expect(tensorflow.resource_ids).toContain("delta-gpu.ncsa.access-ci.org");
expect(tensorflow.versions_by_resource).toBeDefined();
expect(tensorflow.versions_by_resource.delta).toBe("2.10,2.11,2.12");
});
it("should filter by resource with fuzzy matching", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "tensorflow",
resource: "delta",
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["tensorflow"],
fuzz_software: true,
rps: ["delta"],
fuzz_rp: true,
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.resource_filter).toBe("delta");
});
it("should disable fuzzy matching when requested", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "tensorflow",
fuzzy: false,
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["tensorflow"],
});
});
it("should list all software when no query provided", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["*"],
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.total).toBe(3);
expect(responseData.query).toBeNull();
});
it("should respect limit parameter", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
limit: 2,
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.items.length).toBe(2);
});
it("should exclude AI metadata when requested", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "tensorflow",
include_ai_metadata: false,
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.items[0].ai_metadata).toBeUndefined();
});
});
describe("list_all_software", () => {
it("should list all software", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "list_all_software",
arguments: {},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["*"],
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.total).toBe(3);
expect(responseData.resource_filter).toBe("all resources");
});
it("should filter by resource", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "list_all_software",
arguments: {
resource: "anvil",
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["*"],
rps: ["anvil"],
fuzz_rp: true,
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.resource_filter).toBe("anvil");
});
it("should exclude AI metadata by default", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "list_all_software",
arguments: {},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.items[0].ai_metadata).toBeUndefined();
});
it("should include AI metadata when requested", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "list_all_software",
arguments: {
include_ai_metadata: true,
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.items[0].ai_metadata).toBeDefined();
});
});
describe("get_software_details", () => {
it("should get details for a specific software", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: { data: [mockSoftwareWithAI.data[0]] },
});
const result = await server["handleToolCall"]({
params: {
name: "get_software_details",
arguments: {
software_name: "tensorflow",
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["tensorflow"],
fuzz_software: true,
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.found).toBe(true);
expect(responseData.software_name).toBe("tensorflow");
expect(responseData.details).toBeDefined();
expect(responseData.details.name).toBe("TensorFlow");
expect(responseData.details.ai_metadata).toBeDefined();
});
it("should include other matches when multiple results", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "get_software_details",
arguments: {
software_name: "software",
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.found).toBe(true);
expect(responseData.other_matches).toBeDefined();
expect(responseData.other_matches.length).toBe(2);
});
it("should handle software not found", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: { data: [] },
});
const result = await server["handleToolCall"]({
params: {
name: "get_software_details",
arguments: {
software_name: "nonexistent",
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.found).toBe(false);
expect(responseData.message).toContain("No software found");
});
it("should filter by resource when provided", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: { data: [mockSoftwareWithAI.data[0]] },
});
await server["handleToolCall"]({
params: {
name: "get_software_details",
arguments: {
software_name: "tensorflow",
resource: "delta",
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["tensorflow"],
fuzz_software: true,
rps: ["delta"],
fuzz_rp: true,
});
});
});
describe("compare_software_availability", () => {
it("should compare software availability across resources", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "compare_software_availability",
arguments: {
software_names: ["tensorflow", "gromacs"],
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["tensorflow", "gromacs"],
fuzz_software: true,
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.requested_software).toEqual(["tensorflow", "gromacs"]);
expect(responseData.comparison).toBeDefined();
expect(responseData.comparison.length).toBe(2);
expect(responseData.summary).toBeDefined();
});
it("should filter by specific resources when provided", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
await server["handleToolCall"]({
params: {
name: "compare_software_availability",
arguments: {
software_names: ["tensorflow", "gromacs"],
resources: ["anvil", "delta"],
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["tensorflow", "gromacs"],
fuzz_software: true,
rps: ["anvil", "delta"],
fuzz_rp: true,
});
});
it("should build correct availability matrix", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
const result = await server["handleToolCall"]({
params: {
name: "compare_software_availability",
arguments: {
software_names: ["tensorflow", "gromacs"],
},
},
});
const responseData = JSON.parse(result.content[0].text);
const tensorflowComparison = responseData.comparison.find((c) => c.software === "tensorflow");
expect(tensorflowComparison.found).toBe(true);
expect(tensorflowComparison.available_on).toContain("delta");
expect(tensorflowComparison.available_on).toContain("anvil");
const gromacsComparison = responseData.comparison.find((c) => c.software === "gromacs");
expect(gromacsComparison.found).toBe(true);
expect(gromacsComparison.available_on).toContain("anvil");
expect(gromacsComparison.available_on).toContain("expanse");
});
it("should report software not found in summary", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: { data: [mockSoftwareWithAI.data[0]] }, // Only TensorFlow
});
const result = await server["handleToolCall"]({
params: {
name: "compare_software_availability",
arguments: {
software_names: ["tensorflow", "nonexistent"],
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.summary.software_found).toBe(1);
expect(responseData.summary.software_not_found).toContain("nonexistent");
});
});
describe("Result Sorting (Priority-based)", () => {
// Mock data where API returns results in non-optimal order
const mockUnsortedResults = {
data: [
{
software_name: "gpytorch", // contains "pytorch" but not exact
rps: { "aces.tamu.access-ci.org": { rp_name: "aces", rp_resource_id: [], software_versions: "1.0" } },
},
{
software_name: "miniforge3_pytorch", // contains "pytorch"
rps: { "delta.ncsa.access-ci.org": { rp_name: "delta", rp_resource_id: [], software_versions: "1.0" } },
},
{
software_name: "pytorch", // exact match - should be first
rps: {
"anvil.purdue.access-ci.org": { rp_name: "anvil", rp_resource_id: [], software_versions: "2.0" },
"delta.ncsa.access-ci.org": { rp_name: "delta", rp_resource_id: [], software_versions: "2.0" },
},
},
{
software_name: "pytorch-lightning", // starts with "pytorch"
rps: { "aces.tamu.access-ci.org": { rp_name: "aces", rp_resource_id: [], software_versions: "1.0" } },
},
]
};
it("should sort search_software results: exact > starts-with > contains", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockUnsortedResults,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: { query: "pytorch" },
},
});
const responseData = JSON.parse(result.content[0].text);
const names = responseData.items.map((i) => i.name);
expect(names[0]).toBe("pytorch"); // exact match first
expect(names[1]).toBe("pytorch-lightning"); // starts-with second
// contains matches last
expect(names).toContain("gpytorch");
expect(names).toContain("miniforge3_pytorch");
});
it("should sort get_software_details results and return exact match as best", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockUnsortedResults,
});
const result = await server["handleToolCall"]({
params: {
name: "get_software_details",
arguments: { software_name: "pytorch" },
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.details.name).toBe("pytorch"); // exact match as best
expect(responseData.details.available_on_resources).toContain("anvil");
expect(responseData.details.available_on_resources).toContain("delta");
// Other matches should be sorted too
const otherNames = responseData.other_matches.map((m) => m.name);
expect(otherNames[0]).toBe("pytorch-lightning"); // starts-with before contains
});
it("should prioritize exact match in compare_software_availability", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockUnsortedResults,
});
const result = await server["handleToolCall"]({
params: {
name: "compare_software_availability",
arguments: { software_names: ["pytorch"] },
},
});
const responseData = JSON.parse(result.content[0].text);
const pytorchComparison = responseData.comparison.find((c) => c.software === "pytorch");
expect(pytorchComparison.found).toBe(true);
expect(pytorchComparison.resource_count).toBe(2); // anvil and delta from exact "pytorch"
expect(pytorchComparison.available_on).toContain("anvil");
expect(pytorchComparison.available_on).toContain("delta");
});
it("should not sort when no query provided in search_software", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockUnsortedResults,
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: { limit: 10 }, // no query
},
});
const responseData = JSON.parse(result.content[0].text);
// Should return in API order when no query
expect(responseData.items[0].name).toBe("gpytorch");
});
});
describe("API Error Handling", () => {
it("should handle API errors gracefully", async () => {
mockSdsClient.post.mockResolvedValue({
status: 500,
statusText: "Internal Server Error",
});
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "test",
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.error).toBeDefined();
expect(responseData.error).toContain("SDS API error");
});
it("should handle missing API key", async () => {
// Temporarily remove API key
const originalKey = process.env.SDS_API_KEY;
delete process.env.SDS_API_KEY;
delete process.env.VITE_SDS_API_KEY;
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "test",
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.error).toContain("SDS API key not configured");
// Restore API key
if (originalKey) {
process.env.SDS_API_KEY = originalKey;
}
});
it("should handle network errors", async () => {
mockSdsClient.post.mockRejectedValue(new Error("Network error"));
const result = await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
query: "test",
},
},
});
const responseData = JSON.parse(result.content[0].text);
expect(responseData.error).toBeDefined();
});
});
describe("Resource ID Normalization", () => {
it("should normalize XSEDE resource IDs to ACCESS-CI format", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
resource: "stampede2.tacc.xsede.org",
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["*"],
rps: ["stampede2.tacc.access-ci.org"],
fuzz_rp: true,
});
});
it("should normalize GPU/CPU suffixed resource IDs", async () => {
mockSdsClient.post.mockResolvedValue({
status: 200,
data: mockSoftwareWithAI,
});
await server["handleToolCall"]({
params: {
name: "search_software",
arguments: {
resource: "delta-gpu.ncsa.access-ci.org",
},
},
});
expect(mockSdsClient.post).toHaveBeenCalledWith("/api/v1", {
software: ["*"],
rps: ["delta.ncsa.access-ci.org"],
fuzz_rp: true,
});
});
});
describe("Tool Definitions", () => {
it("should define search_software tool with correct schema", () => {
const tools = server["getTools"]();
const searchTool = tools.find((t) => t.name === "search_software");
expect(searchTool).toBeDefined();
expect(searchTool?.description).toContain("fuzzy matching");
expect(searchTool?.inputSchema.properties.query).toBeDefined();
expect(searchTool?.inputSchema.properties.resource).toBeDefined();
expect(searchTool?.inputSchema.properties.fuzzy).toBeDefined();
expect(searchTool?.inputSchema.properties.include_ai_metadata).toBeDefined();
expect(searchTool?.inputSchema.properties.limit).toBeDefined();
});
it("should define list_all_software tool", () => {
const tools = server["getTools"]();
const listTool = tools.find((t) => t.name === "list_all_software");
expect(listTool).toBeDefined();
expect(listTool?.description).toContain("List all");
expect(listTool?.description).toContain("software");
});
it("should define get_software_details tool with required parameters", () => {
const tools = server["getTools"]();
const detailsTool = tools.find((t) => t.name === "get_software_details");
expect(detailsTool).toBeDefined();
expect(detailsTool?.inputSchema.required).toContain("software_name");
});
it("should define compare_software_availability tool with required parameters", () => {
const tools = server["getTools"]();
const compareTool = tools.find((t) => t.name === "compare_software_availability");
expect(compareTool).toBeDefined();
expect(compareTool?.inputSchema.required).toContain("software_names");
});
});
});