Skip to main content
Glama

OpenAPI MCP Server

MIT License
2,130
150
  • Apple
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" import { readFile } from "fs/promises" import { OpenAPISpecLoader, ExtendedTool } from "../src/openapi-loader" import { OpenAPIV3 } from "openapi-types" import { Tool } from "@modelcontextprotocol/sdk/types.js" // Mock dependencies vi.mock("fs/promises") vi.mock("js-yaml", async () => { const actualJsYamlMod = await vi.importActual<typeof import("js-yaml")>("js-yaml") // The SUT (System Under Test) uses "import yaml from 'js-yaml'", // which means it expects 'js-yaml' to have a default export. // The mocked 'load' function for this default export should call the actual 'load' from js-yaml. // According to @types/js-yaml, the 'load' function is a direct export of the module. const realLoadFn = actualJsYamlMod.load if (typeof realLoadFn !== "function") { // This would be unexpected if @types/js-yaml is correct and js-yaml is installed. console.error( "Vitest mock issue: js-yaml .load function not found on actualJsYamlMod as per types.", actualJsYamlMod, ) throw new Error("Vitest mock setup: actualJsYamlMod.load is not a function") } return { default: { load: vi.fn((content: string) => realLoadFn(content)), }, // Provide other exports as well, consistent with the actual module, in case they are ever used. load: vi.fn((content: string) => realLoadFn(content)), // safeLoad: vi.fn((content: string) => actualJsYamlMod.safeLoad(content)), // Temporarily remove if causing type issues // Add other js-yaml exports if necessary for full fidelity, though 'load' is the key one here. } }) // Mock fetch globally for tests that might use it global.fetch = vi.fn() describe("OpenAPISpecLoader", () => { let openAPILoader: OpenAPISpecLoader const mockOpenAPISpec: OpenAPIV3.Document = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0", }, paths: { "/users": { get: { operationId: "getUsers", summary: "Get all users", description: "Returns a list of users", parameters: [ { name: "limit", in: "query", description: "Maximum number of users to return", required: false, schema: { type: "integer", }, }, ], responses: {}, }, post: { operationId: "createUser", summary: "Create a user", description: "Creates a new user", responses: {}, }, }, "/users/{id}": { get: { operationId: "getUserById", summary: "Get user by ID", description: "Returns a user by ID", parameters: [ { name: "id", in: "path", description: "User ID", required: true, schema: { type: "string", }, }, ], responses: {}, }, }, }, } beforeEach(() => { openAPILoader = new OpenAPISpecLoader() }) afterEach(() => { vi.resetAllMocks() }) describe("loadOpenAPISpec", () => { it("should load spec from URL", async () => { const url = "https://example.com/api-spec.json" vi.mocked(fetch).mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify(mockOpenAPISpec), json: async () => mockOpenAPISpec, // Though text() is used in implementation } as Response) const result = await openAPILoader.loadOpenAPISpec(url, "url") expect(fetch).toHaveBeenCalledWith(url) expect(result).toEqual(mockOpenAPISpec) }) it("should load spec from local file (JSON)", async () => { const filePath = "./api-spec.json" const fileContent = JSON.stringify(mockOpenAPISpec) vi.mocked(readFile).mockResolvedValueOnce(fileContent) const result = await openAPILoader.loadOpenAPISpec(filePath, "file") expect(readFile).toHaveBeenCalledWith(filePath, "utf-8") expect(result).toEqual(mockOpenAPISpec) }) it("should load spec from local file (YAML)", async () => { const filePath = "./api-spec.yaml" const yamlContent = ` openapi: 3.0.0 info: title: Test API YAML version: 1.0.0 paths: /test: get: summary: Test YAML endpoint responses: '200': description: Successful response ` const expectedSpecObject = { openapi: "3.0.0", info: { title: "Test API YAML", version: "1.0.0", }, paths: { "/test": { get: { summary: "Test YAML endpoint", responses: { "200": { description: "Successful response", }, }, }, }, }, } vi.mocked(readFile).mockResolvedValueOnce(yamlContent) const result = await openAPILoader.loadOpenAPISpec(filePath, "file") expect(readFile).toHaveBeenCalledWith(filePath, "utf-8") expect(result).toEqual(expectedSpecObject) }) it("should load spec from inline content (JSON)", async () => { const inlineContent = JSON.stringify(mockOpenAPISpec) const result = await openAPILoader.loadOpenAPISpec("inline", "inline", inlineContent) expect(result).toEqual(mockOpenAPISpec) }) it("should load spec from inline content (YAML)", async () => { const yamlContent = ` openapi: 3.0.0 info: title: Inline YAML API version: 1.0.0 paths: /inline: get: summary: Inline endpoint responses: '200': description: Success ` const expectedSpec = { openapi: "3.0.0", info: { title: "Inline YAML API", version: "1.0.0", }, paths: { "/inline": { get: { summary: "Inline endpoint", responses: { "200": { description: "Success", }, }, }, }, }, } const result = await openAPILoader.loadOpenAPISpec("inline", "inline", yamlContent) expect(result).toEqual(expectedSpec) }) it("should load spec from stdin", async () => { const stdinContent = JSON.stringify(mockOpenAPISpec) // Mock process.stdin const mockStdin = { setEncoding: vi.fn(), on: vi.fn(), resume: vi.fn(), } // Replace process.stdin temporarily const originalStdin = process.stdin Object.defineProperty(process, "stdin", { value: mockStdin, configurable: true, }) // Set up the stdin mock to simulate data flow // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type mockStdin.on.mockImplementation((event: string, callback: Function) => { if (event === "data") { setTimeout(() => callback(stdinContent), 0) } else if (event === "end") { setTimeout(() => callback(), 10) } return mockStdin }) const result = await openAPILoader.loadOpenAPISpec("stdin", "stdin") expect(mockStdin.setEncoding).toHaveBeenCalledWith("utf8") expect(mockStdin.resume).toHaveBeenCalled() expect(result).toEqual(mockOpenAPISpec) // Restore original stdin Object.defineProperty(process, "stdin", { value: originalStdin, configurable: true, }) }) it("should throw error if URL fetch fails", async () => { const url = "https://example.com/api-spec.json" vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found", } as Response) await expect(openAPILoader.loadOpenAPISpec(url, "url")).rejects.toThrow( "Failed to load OpenAPI spec from url: HTTP 404: Not Found", ) }) it("should throw error if file reading fails", async () => { const filePath = "./api-spec.json" const error = new Error("File not found") vi.mocked(readFile).mockRejectedValueOnce(error) await expect(openAPILoader.loadOpenAPISpec(filePath, "file")).rejects.toThrow( "Failed to load OpenAPI spec from file: File not found", ) }) it("should throw error if inline content is missing", async () => { await expect(openAPILoader.loadOpenAPISpec("inline", "inline")).rejects.toThrow( "Inline content is required when using 'inline' input method", ) }) it("should throw error if stdin provides empty content", async () => { // Mock process.stdin for empty content const mockStdin = { setEncoding: vi.fn(), on: vi.fn(), resume: vi.fn(), } const originalStdin = process.stdin Object.defineProperty(process, "stdin", { value: mockStdin, configurable: true, }) // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type mockStdin.on.mockImplementation((event: string, callback: Function) => { if (event === "end") { setTimeout(() => callback(), 0) } return mockStdin }) await expect(openAPILoader.loadOpenAPISpec("stdin", "stdin")).rejects.toThrow( "Failed to load OpenAPI spec from stdin: No data received from stdin", ) Object.defineProperty(process, "stdin", { value: originalStdin, configurable: true, }) }) it("should throw error for invalid JSON/YAML content", async () => { const invalidContent = "{ invalid json content" await expect( openAPILoader.loadOpenAPISpec("inline", "inline", invalidContent), ).rejects.toThrow(/Failed to parse as JSON or YAML/) }) it("should throw error for empty content", async () => { await expect(openAPILoader.loadOpenAPISpec("inline", "inline", "")).rejects.toThrow( "Failed to load OpenAPI spec from inline", ) }) it("should throw error for unsupported input method", async () => { await expect(openAPILoader.loadOpenAPISpec("test", "unsupported" as any)).rejects.toThrow( "Unsupported input method: unsupported", ) }) // Backward compatibility test it("should maintain backward compatibility with old interface", async () => { const url = "https://example.com/api-spec.json" vi.mocked(fetch).mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify(mockOpenAPISpec), } as Response) // Test the old interface (without specifying input method) const result = await openAPILoader.loadOpenAPISpec(url) expect(fetch).toHaveBeenCalledWith(url) expect(result).toEqual(mockOpenAPISpec) }) }) describe("parseOpenAPISpec with disableAbbreviation", () => { it("should not abbreviate tool names when disableAbbreviation is true", () => { const loader = new OpenAPISpecLoader({ disableAbbreviation: true }) const spec: OpenAPIV3.Document = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/users/management/authorization-groups": { get: { operationId: "getUserManagementAuthorizationGroups", summary: "Get all user management authorization groups", responses: {}, }, }, }, } const tools = loader.parseOpenAPISpec(spec) const toolId = Array.from(tools.keys())[0] // Should not be abbreviated expect(toolId).toContain("GET::users__management__authorization-groups") const tool = tools.get(toolId)! expect(tool.name).toContain("get-user-management-authorization-groups") }) it("should handle various number-letter combinations when disableAbbreviation is true", () => { const loader = new OpenAPISpecLoader({ disableAbbreviation: true }) expect(loader.abbreviateOperationId("api2DataProcessor")).toBe("api2-data-processor") expect(loader.abbreviateOperationId("blockchain2Handler")).toBe("blockchain2-handler") expect(loader.abbreviateOperationId("v1ApiService")).toBe("v1-api-service") expect(loader.abbreviateOperationId("oauth2TokenManager")).toBe("oauth2-token-manager") }) }) describe("parseOpenAPISpec", () => { it("should convert OpenAPI paths to MCP tools", () => { const tools = openAPILoader.parseOpenAPISpec(mockOpenAPISpec) expect(tools.size).toBe(3) expect(tools.has("GET::users")).toBe(true) expect(tools.has("POST::users")).toBe(true) expect(tools.has("GET::users__---id")).toBe(true) }) it("should set correct tool properties", () => { const tools = openAPILoader.parseOpenAPISpec(mockOpenAPISpec) const getUsersTool = tools.get("GET::users") as Tool expect(getUsersTool).toBeDefined() expect(getUsersTool.name).toBe("get-usrs") expect(getUsersTool.description).toBe("Returns a list of users") expect(getUsersTool.inputSchema).toEqual({ type: "object", properties: { limit: { type: "integer", description: "Maximum number of users to return", "x-parameter-location": "query", }, }, }) }) it("should handle required parameters", () => { const tools = openAPILoader.parseOpenAPISpec(mockOpenAPISpec) const getUserByIdTool = tools.get("GET::users__---id") as Tool expect(getUserByIdTool).toBeDefined() expect(getUserByIdTool.inputSchema).toEqual({ type: "object", properties: { id: { type: "string", description: "User ID", "x-parameter-location": "path", }, }, required: ["id"], }) }) it("should use operationId as tool name when available", () => { const tools = openAPILoader.parseOpenAPISpec(mockOpenAPISpec) const getUsersTool = tools.get("GET::users") as Tool expect(getUsersTool.name).toBe("get-usrs") }) it("should fallback to summary when operationId is missing", () => { const specWithoutOperationId: OpenAPIV3.Document = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/users": { get: { summary: "Get all users from the system", description: "Returns a list of users", responses: {}, }, }, "/orders": { post: { summary: "Create new order", description: "Creates a new order in the system", responses: {}, }, }, }, } const tools = openAPILoader.parseOpenAPISpec(specWithoutOperationId) const getUsersTool = tools.get("GET::users") as Tool expect(getUsersTool).toBeDefined() expect(getUsersTool.name).toBe("get-all-users-from-the-system") const createOrderTool = tools.get("POST::orders") as Tool expect(createOrderTool).toBeDefined() expect(createOrderTool.name).toBe("create-new-order") }) it("should fallback to method and path when both operationId and summary are missing", () => { const specWithoutOperationIdOrSummary: OpenAPIV3.Document = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/users": { get: { description: "Returns a list of users", responses: {}, }, }, "/users/{id}": { delete: { description: "Deletes a user by ID", responses: {}, }, }, "/api/v1/products": { post: { description: "Creates a new product", responses: {}, }, }, }, } const tools = openAPILoader.parseOpenAPISpec(specWithoutOperationIdOrSummary) const getUsersTool = tools.get("GET::users") as Tool expect(getUsersTool).toBeDefined() expect(getUsersTool.name).toBe("get-users") const deleteUserTool = tools.get("DELETE::users__---id") as Tool expect(deleteUserTool).toBeDefined() expect(deleteUserTool.name).toBe("delete-users-id") const createProductTool = tools.get("POST::api__v1__products") as Tool expect(createProductTool).toBeDefined() expect(createProductTool.name).toBe("post-api-v-1-products") }) it("should handle complex path structures in fallback names", () => { const specWithComplexPaths: OpenAPIV3.Document = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/service/health-check": { get: { responses: {}, }, }, "/api/v2/user-management/profiles/{userId}/settings": { put: { responses: {}, }, }, }, } const tools = openAPILoader.parseOpenAPISpec(specWithComplexPaths) const updateSettingsTool = tools.get( "PUT::api__v2__user-management__profiles__---userId__settings", ) as Tool expect(updateSettingsTool).toBeDefined() expect(updateSettingsTool.name).toBe("put-api-v-2-user-management-profiles-user-id-settings") const healthCheckTool = tools.get("GET::service__health-check") as Tool expect(healthCheckTool).toBeDefined() expect(healthCheckTool.name).toBe("get-service-health-check") }) // New tests for Input Schema Composition and $ref inlining it("should merge primitive request bodies into a 'body' property and mark required", () => { const spec: OpenAPIV3.Document = { openapi: "3.0.0", info: { title: "Primitive API", version: "1.0.0" }, paths: { "/echo": { post: { summary: "Echo primitive", requestBody: { content: { "application/json": { schema: { type: "string" } } }, }, responses: { "200": { description: "OK" } }, }, }, }, } const tools = openAPILoader.parseOpenAPISpec(spec) const tool = tools.get("POST::echo")! expect(tool.inputSchema.properties).toHaveProperty("body") expect((tool.inputSchema.properties! as any).body.type).toBe("string") expect(tool.inputSchema.required).toEqual(["body"]) }) }) })

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ivo-toby/mcp-openapi-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server