Skip to main content
Glama

OpenAPI MCP Server

MIT License
2,130
150
  • Apple
import { describe, it, expect, vi, beforeEach } from "vitest" import { ApiClient } from "../src/api-client.js" import { StaticAuthProvider } from "../src/auth-provider.js" import { OpenAPISpecLoader } from "../src/openapi-loader.js" import { Tool } from "@modelcontextprotocol/sdk/types.js" describe("ApiClient Dynamic Meta-Tools", () => { let apiClient: ApiClient let mockAxios: any let mockSpecLoader: OpenAPISpecLoader beforeEach(() => { mockAxios = { create: vi.fn().mockReturnThis(), request: vi.fn(), get: vi.fn(), post: vi.fn(), } mockSpecLoader = new OpenAPISpecLoader() apiClient = new ApiClient("https://api.example.com", new StaticAuthProvider(), mockSpecLoader) // Mock the axios instance ;(apiClient as any).axiosInstance = mockAxios }) describe("LIST-API-ENDPOINTS", () => { it("should handle LIST-API-ENDPOINTS meta-tool without making HTTP request", async () => { const openApiSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/users": { get: { summary: "Get users", description: "Retrieve all users", responses: {}, }, post: { summary: "Create user", description: "Create a new user", responses: {}, }, }, "/users/{id}": { get: { summary: "Get user by ID", description: "Retrieve a specific user", responses: {}, }, }, }, } apiClient.setOpenApiSpec(openApiSpec) const result = await apiClient.executeApiCall("LIST-API-ENDPOINTS", {}) expect(result).toEqual({ endpoints: [ { method: "GET", path: "/users", summary: "Get users", description: "Retrieve all users", operationId: "", tags: [], }, { method: "POST", path: "/users", summary: "Create user", description: "Create a new user", operationId: "", tags: [], }, { method: "GET", path: "/users/{id}", summary: "Get user by ID", description: "Retrieve a specific user", operationId: "", tags: [], }, ], total: 3, note: "Use INVOKE-API-ENDPOINT to call specific endpoints with the path parameter", }) // Verify no HTTP request was made expect(mockAxios.request).not.toHaveBeenCalled() expect(mockAxios.get).not.toHaveBeenCalled() expect(mockAxios.post).not.toHaveBeenCalled() }) it("should work without OpenAPI spec using fallback", async () => { const tools = new Map<string, Tool>([ [ "GET::users", { name: "Get Users", description: "List all users", inputSchema: { type: "object", properties: {} }, }, ], [ "POST::users", { name: "Create User", description: "Create a user", inputSchema: { type: "object", properties: {} }, }, ], ]) apiClient.setTools(tools) const result = await apiClient.executeApiCall("LIST-API-ENDPOINTS", {}) expect(result.endpoints).toHaveLength(2) expect(result.note).toContain("Limited endpoint information") }) }) describe("GET-API-ENDPOINT-SCHEMA", () => { it("should handle GET-API-ENDPOINT-SCHEMA meta-tool", async () => { const openApiSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/users": { get: { summary: "Get users", description: "Retrieve all users", parameters: [ { name: "page", in: "query", schema: { type: "integer" }, }, ], responses: {}, }, }, }, } apiClient.setOpenApiSpec(openApiSpec as any) const result = await apiClient.executeApiCall("GET-API-ENDPOINT-SCHEMA", { endpoint: "/users", }) expect(result.path).toBe("/users") expect(result.operations).toHaveLength(1) expect(result.operations[0].method).toBe("GET") expect(result.operations[0].summary).toBe("Get users") }) }) describe("INVOKE-API-ENDPOINT", () => { it("should handle INVOKE-API-ENDPOINT meta-tool with direct HTTP request", async () => { const openApiSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/users": { get: { summary: "Get users", }, }, }, } apiClient.setOpenApiSpec(openApiSpec as any) mockAxios.request.mockResolvedValue({ data: [{ id: 1, name: "John" }] }) const result = await apiClient.executeApiCall("INVOKE-API-ENDPOINT", { endpoint: "/users", method: "GET", params: { page: 1 }, }) expect(result).toEqual([{ id: 1, name: "John" }]) expect(mockAxios.request).toHaveBeenCalledWith({ method: "get", url: "/users", headers: {}, params: { page: 1 }, }) }) }) it("should provide specific error messages for GET-API-ENDPOINT-SCHEMA with missing endpoint", async () => { const tools = new Map([ [ "GET-API-ENDPOINT-SCHEMA", { name: "get-api-endpoint-schema", description: "Get schema", inputSchema: { type: "object" as const, properties: {} }, } as any, ], ]) apiClient.setTools(tools) await expect(apiClient.executeApiCall("GET-API-ENDPOINT-SCHEMA", {})).rejects.toThrow( "Missing required parameter 'endpoint' for tool 'GET-API-ENDPOINT-SCHEMA'", ) }) it("should provide specific error messages for INVOKE-API-ENDPOINT with missing endpoint", async () => { const tools = new Map([ [ "INVOKE-API-ENDPOINT", { name: "invoke-api-endpoint", description: "Invoke endpoint", inputSchema: { type: "object" as const, properties: {} }, } as any, ], ]) apiClient.setTools(tools) await expect(apiClient.executeApiCall("INVOKE-API-ENDPOINT", {})).rejects.toThrow( "Missing required parameter 'endpoint' for tool 'INVOKE-API-ENDPOINT'", ) }) it("should provide specific error messages for GET-API-ENDPOINT-SCHEMA with invalid endpoint", async () => { const tools = new Map([ [ "GET-API-ENDPOINT-SCHEMA", { name: "get-api-endpoint-schema", description: "Get schema", inputSchema: { type: "object" as const, properties: {} }, } as any, ], ]) apiClient.setTools(tools) // Mock OpenAPI spec with no matching endpoint const mockSpec = { openapi: "3.0.0", info: { title: "Test", version: "1.0.0" }, paths: {}, } as any apiClient.setOpenApiSpec(mockSpec) await expect( apiClient.executeApiCall("GET-API-ENDPOINT-SCHEMA", { endpoint: "/invalid" }), ).rejects.toThrow("No endpoint found for path '/invalid' in tool 'GET-API-ENDPOINT-SCHEMA'") }) }) // Regression test for Issue #33: Path parameter replacement bug describe("Issue #33 Regression Test", () => { it("should correctly replace path parameters without affecting similar text in path segments", async () => { // This test specifically addresses the bug described in issue #33: // Original bug: /inputs/{input} with input=00000 would result in /00000s/input // Expected behavior: /inputs/{input} with input=00000 should result in /inputs/00000 const mockSpecLoader = new OpenAPISpecLoader() const mockApiClient = new ApiClient( "https://api.example.com", new StaticAuthProvider(), mockSpecLoader, ) // Create a mock OpenAPI spec with the problematic path structure const testSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/inputs/{input}": { get: { operationId: "getInput", parameters: [ { name: "input", in: "path", required: true, schema: { type: "string" as const }, }, ], responses: { "200": { description: "Success" } }, }, }, }, } // Set the spec and generate tools mockApiClient.setOpenApiSpec(testSpec as any) const tools = mockSpecLoader.parseOpenAPISpec(testSpec as any) mockApiClient.setTools(tools) // Mock axios to capture the actual request URL let capturedConfig: any = null const mockAxios = vi.fn().mockImplementation((config) => { capturedConfig = config return Promise.resolve({ data: { success: true } }) }) ;(mockApiClient as any).axiosInstance = mockAxios // Execute the API call with the problematic parameter value from issue #33 const toolId = "GET::inputs__---input" await mockApiClient.executeApiCall(toolId, { input: "00000" }) // Verify the URL was correctly constructed expect(capturedConfig).toBeDefined() expect(capturedConfig.url).toBe("/inputs/00000") // Explicitly verify the bug is NOT present expect(capturedConfig.url).not.toBe("/00000s/input") }) it("should handle multiple path parameters without substring replacement issues", async () => { // Additional test to ensure the fix works with multiple parameters const mockSpecLoader = new OpenAPISpecLoader() const mockApiClient = new ApiClient( "https://api.example.com", new StaticAuthProvider(), mockSpecLoader, ) const testSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/users/{userId}/posts/{postId}": { get: { operationId: "getUserPost", parameters: [ { name: "userId", in: "path", required: true, schema: { type: "string" as const }, }, { name: "postId", in: "path", required: true, schema: { type: "string" as const }, }, ], responses: { "200": { description: "Success" } }, }, }, }, } mockApiClient.setOpenApiSpec(testSpec as any) const tools = mockSpecLoader.parseOpenAPISpec(testSpec as any) mockApiClient.setTools(tools) let capturedConfig: any = null const mockAxios = vi.fn().mockImplementation((config) => { capturedConfig = config return Promise.resolve({ data: { success: true } }) }) ;(mockApiClient as any).axiosInstance = mockAxios const toolId = "GET::users__---userId__posts__---postId" await mockApiClient.executeApiCall(toolId, { userId: "123", postId: "456" }) expect(capturedConfig.url).toBe("/users/123/posts/456") }) }) /* * Issue #33 Fix: Path Parameter Replacement Bug * * The bug was in the tool ID generation and path parameter replacement: * * OLD BEHAVIOR: * - Path: /inputs/{input} with parameter input=00000 * - Tool ID generation removed braces: /inputs/input * - Parameter replacement: /inputs/input -> /00000s/input (WRONG!) * * NEW BEHAVIOR: * - Path: /inputs/{input} with parameter input=00000 * - Tool ID generation transforms braces to markers: /inputs/---input * - Parameter replacement: /inputs/---input -> /inputs/00000 (CORRECT!) * * The fix transforms {param} to ---param in tool IDs to preserve parameter * location information, then updates the parameter replacement logic to * handle these markers correctly. */ // Tests for Issue #33 and PR #38 review comment edge cases describe("PR #38 Review Comment Fixes", () => { describe("Parameter Matching Precision in API Client", () => { it("should not partially match parameter names that are substrings of path segments", async () => { const mockSpecLoader = new OpenAPISpecLoader() const mockApiClient = new ApiClient( "https://api.example.com", new StaticAuthProvider(), mockSpecLoader, ) // Test case where parameter names could cause substring collisions const testSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/api/users/{userid}/info/{user}": { get: { operationId: "getUserInfo", parameters: [ { name: "userid", in: "path", required: true, schema: { type: "string" as const }, }, { name: "user", in: "path", required: true, schema: { type: "string" as const }, }, ], responses: { "200": { description: "Success" } }, }, }, }, } mockApiClient.setOpenApiSpec(testSpec as any) const tools = mockSpecLoader.parseOpenAPISpec(testSpec as any) mockApiClient.setTools(tools) let capturedConfig: any = null const mockAxios = vi.fn().mockImplementation((config) => { capturedConfig = config return Promise.resolve({ data: { success: true } }) }) ;(mockApiClient as any).axiosInstance = mockAxios // This should NOT cause substring replacement issues const toolId = "GET::api__users__---userid__info__---user" await mockApiClient.executeApiCall(toolId, { userid: "456", user: "123" }) expect(capturedConfig.url).toBe("/api/users/456/info/123") // Verify no partial matches occurred expect(capturedConfig.url).not.toContain("456id") // Would indicate partial match of "user" in "userid" expect(capturedConfig.url).not.toContain("123id") }) it("should handle parameters with similar names without cross-contamination", async () => { const mockSpecLoader = new OpenAPISpecLoader() const mockApiClient = new ApiClient( "https://api.example.com", new StaticAuthProvider(), mockSpecLoader, ) const testSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/api/{id}/data/{idNum}": { get: { operationId: "getIdData", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" as const }, }, { name: "idNum", in: "path", required: true, schema: { type: "string" as const }, }, ], responses: { "200": { description: "Success" } }, }, }, }, } mockApiClient.setOpenApiSpec(testSpec as any) const tools = mockSpecLoader.parseOpenAPISpec(testSpec as any) mockApiClient.setTools(tools) let capturedConfig: any = null const mockAxios = vi.fn().mockImplementation((config) => { capturedConfig = config return Promise.resolve({ data: { success: true } }) }) ;(mockApiClient as any).axiosInstance = mockAxios const toolId = "GET::api__---id__data__---idNum" await mockApiClient.executeApiCall(toolId, { id: "ABC", idNum: "789" }) expect(capturedConfig.url).toBe("/api/ABC/data/789") // Ensure no cross-contamination between similar parameter names expect(capturedConfig.url).not.toContain("ABCNum") expect(capturedConfig.url).not.toContain("789Num") }) it("should properly handle parameter replacement with double underscore boundaries", async () => { const mockSpecLoader = new OpenAPISpecLoader() const mockApiClient = new ApiClient( "https://api.example.com", new StaticAuthProvider(), mockSpecLoader, ) const testSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/api/v1/{param}/nested/{param2}": { get: { operationId: "getNestedParam", parameters: [ { name: "param", in: "path", required: true, schema: { type: "string" as const }, }, { name: "param2", in: "path", required: true, schema: { type: "string" as const }, }, ], responses: { "200": { description: "Success" } }, }, }, }, } mockApiClient.setOpenApiSpec(testSpec as any) const tools = mockSpecLoader.parseOpenAPISpec(testSpec as any) mockApiClient.setTools(tools) let capturedConfig: any = null const mockAxios = vi.fn().mockImplementation((config) => { capturedConfig = config return Promise.resolve({ data: { success: true } }) }) ;(mockApiClient as any).axiosInstance = mockAxios const toolId = "GET::api__v1__---param__nested__---param2" await mockApiClient.executeApiCall(toolId, { param: "VALUE1", param2: "VALUE2" }) expect(capturedConfig.url).toBe("/api/v1/VALUE1/nested/VALUE2") // Verify boundaries are respected and no partial replacement occurs expect(capturedConfig.url).not.toContain("VALUE12") // param2 should not be affected by param replacement }) }) describe("Sanitization Edge Cases", () => { it("should handle paths with consecutive hyphens correctly in API calls", async () => { const mockSpecLoader = new OpenAPISpecLoader() const mockApiClient = new ApiClient( "https://api.example.com", new StaticAuthProvider(), mockSpecLoader, ) // Create a spec with a path that has consecutive hyphens that should be preserved const testSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, paths: { "/api/resource---name/items": { get: { operationId: "getResourceItems", responses: { "200": { description: "Success" } }, }, }, }, } mockApiClient.setOpenApiSpec(testSpec as any) const tools = mockSpecLoader.parseOpenAPISpec(testSpec as any) mockApiClient.setTools(tools) let capturedConfig: any = null const mockAxios = vi.fn().mockImplementation((config) => { capturedConfig = config return Promise.resolve({ data: { success: true } }) }) ;(mockApiClient as any).axiosInstance = mockAxios // The tool ID should preserve the triple hyphens properly const toolId = "GET::api__resource---name__items" await mockApiClient.executeApiCall(toolId, {}) expect(capturedConfig.url).toBe("/api/resource---name/items") }) }) })

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