Skip to main content
Glama
resource-registration.test.ts15.5 kB
// External imports import { OpenApiSpec } from '@mcpify/core'; import type { OpenApiSpecOptions } from '@mcpify/core'; import { testApp } from '@mcpify/core/test'; import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import Oas from 'oas'; import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; // Define specific callback types for our test implementation to avoid using 'Function' type type ResourceCallbackFn = (uri: string, args: Record<string, unknown>) => Promise<unknown>; type ToolCallbackFn = (args: Record<string, unknown>) => Promise<unknown>; interface ResourceCall { id: string; uri: string | object; callback: ResourceCallbackFn; } interface ToolCall { id: string; description: string; schema?: Record<string, unknown>; hints?: ToolAnnotations; callback: ToolCallbackFn; } // This is a simplified test implementation that doesn't match the full McpServer interface, // but provides the functionality we need for testing class TestMcpServer { public resourceCalls: ResourceCall[] = []; public toolCalls: ToolCall[] = []; // Capture resource registrations for later inspection resource( id: string, uri: string | object, callback: ResourceCallbackFn, ): { unregister: () => void } { this.resourceCalls.push({ id, uri, callback }); return { unregister: () => { // Remove this resource from the tracked calls const index = this.resourceCalls.findIndex((call) => call.id === id); if (index !== -1) { this.resourceCalls.splice(index, 1); } }, }; } // Capture tool registrations for later inspection tool( id: string, description: string, schemaOrHints: Record<string, unknown> | ToolAnnotations, callbackOrHints: ToolCallbackFn | ToolAnnotations, callback?: ToolCallbackFn, ): { unregister: () => void } { // Handle different overload cases if (typeof callbackOrHints === 'function') { // (id, description, schema, callback) format this.toolCalls.push({ id, description, // When we're in this code path, schemaOrHints is already a Record<string, unknown> schema: schemaOrHints, callback: callbackOrHints, }); } else if (callback) { // (id, description, schema, hints, callback) format this.toolCalls.push({ id, description, // Similar to the first case, schema is already a Record<string, unknown> in this context schema: schemaOrHints, hints: callbackOrHints, callback, }); } else { // (id, description, hints, callback) format this.toolCalls.push({ id, description, hints: schemaOrHints as ToolAnnotations, // Convert to unknown first for type safety callback: callbackOrHints as unknown as ToolCallbackFn, }); } // Return a simple registered tool object return { unregister: () => { // Remove this tool from the tracked calls const index = this.toolCalls.findIndex((call) => call.id === id); if (index !== -1) { this.toolCalls.splice(index, 1); } }, }; } } describe('MCPify Integration', () => { // Define our schemas using Zod for type safety const PetSchema = z.object({ id: z.number().int().optional(), name: z.string(), tag: z.string().optional(), }); const PetsResponseSchema = z.array(PetSchema); const CreatePetSchema = z.object({ name: z.string(), tag: z.string().optional(), }); // Simple example OpenAPI specification for testing const simpleOpenApiSpec = { openapi: '3.0.0', info: { title: 'Test API', version: '1.0.0', description: 'A simple API for testing MCPify', }, servers: [ { url: 'https://api.example.com', }, ], paths: { '/pets': { get: { summary: 'List all pets', operationId: 'listPets', parameters: [ { name: 'limit', in: 'query', description: 'Maximum number of pets to return', required: false, schema: { type: 'integer', format: 'int32', }, }, ], responses: { '200': { description: 'A paged array of pets', content: { 'application/json': { schema: zodToJsonSchema(PetsResponseSchema), }, }, }, }, }, post: { summary: 'Create a pet', operationId: 'createPet', requestBody: { required: true, content: { 'application/json': { schema: zodToJsonSchema(CreatePetSchema), }, }, }, responses: { '201': { description: 'Pet created', }, }, }, }, '/pets/{petId}': { get: { summary: 'Get a pet by ID', operationId: 'getPetById', parameters: [ { name: 'petId', in: 'path', description: 'ID of pet to fetch', required: true, schema: { type: 'string', }, }, ], responses: { '200': { description: 'Expected response to a valid request', content: { 'application/json': { schema: zodToJsonSchema(PetSchema), }, }, }, }, }, delete: { summary: 'Delete a specific pet', operationId: 'deletePetById', parameters: [ { name: 'petId', in: 'path', description: 'The id of the pet to delete', required: true, schema: { type: 'string', }, }, ], responses: { '204': { description: 'Pet deleted successfully', }, }, }, }, }, components: { schemas: { Pet: zodToJsonSchema( // We create a modified schema with stricter requirements for the component definition PetSchema.extend({ // Ensure id is required in this schema definition id: z.number().int(), }), ), }, }, }; describe('End-to-end OpenAPI to MCP conversion', () => { let testServer: TestMcpServer; let openApiSpec: OpenApiSpec; let oas: Oas; beforeEach(() => { testServer = new TestMcpServer(); // Create properly typed OpenAPI spec object and convert to Oas instance // Note: Using 'as any' here is necessary since we're using a simplified test spec // that doesn't match the full OASDocument type but works for our tests // eslint-disable-next-line @typescript-eslint/no-explicit-any oas = new Oas(simpleOpenApiSpec as any); const { app } = testApp(); // Create options with a properly implemented App const options: OpenApiSpecOptions = { app, baseUrl: 'https://api.example.com', }; openApiSpec = OpenApiSpec.from(oas, options); }); it('should parse the OpenAPI spec successfully', () => { expect(openApiSpec).toBeDefined(); }); it('should create MCP resources from the OpenAPI spec', () => { // Call the createResources method // Type assertion needed since TestMcpServer doesn't implement the full McpServer interface openApiSpec.createResources(testServer as unknown as McpServer); // Verify that resources were registered expect(testServer.resourceCalls.length).toBeGreaterThan(0); // Verify that a resource is registered with the expected operation ID const operationIds = testServer.resourceCalls.map((call) => call.id); // Look for expected API operation IDs expect(operationIds).toContain('getPetById'); // Verify that resource callbacks are provided for (const call of testServer.resourceCalls) { // Verify we have a callback function expect(typeof call.callback).toBe('function'); } // Verify URIs for resources const baseUrl = 'https://api.example.com'; // 'listPets' has a query parameter, so it should not be registered as a resource // Only operations with exclusively path parameters can be resources const getPetCall = testServer.resourceCalls.find((call) => call.id === 'getPetById'); expect(getPetCall).toBeDefined(); expect(getPetCall?.uri).toBeInstanceOf(ResourceTemplate); // Access the uriTemplate through the ResourceTemplate's getter const resourceTemplate = getPetCall?.uri as ResourceTemplate; const templatePattern = resourceTemplate.uriTemplate.toString(); expect(templatePattern).toBe(`${baseUrl}/pets/{petId}`); }); it('should not register non-resource operations', () => { // Call the createResources method openApiSpec.createResources(testServer as unknown as McpServer); // POST operations should not be registered as resources const createPetCall = testServer.resourceCalls.find((call) => call.id === 'createPet'); expect(createPetCall).toBeUndefined(); // DELETE operations should not be registered as resources const deletePetCall = testServer.resourceCalls.find((call) => call.id === 'deletePetById'); expect(deletePetCall).toBeUndefined(); }); it('should register only GET operations as resources', () => { // Define a spec with various HTTP methods for the same path const methodsSpec = { openapi: '3.0.0', info: { title: 'HTTP Methods API', version: '1.0.0' }, paths: { '/items/{itemId}': { get: { operationId: 'getItem', parameters: [ { name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }, ], }, post: { operationId: 'createItem', parameters: [ { name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }, ], }, put: { operationId: 'updateItem', parameters: [ { name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }, ], }, patch: { operationId: 'patchItem', parameters: [ { name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }, ], }, delete: { operationId: 'deleteItem', parameters: [ { name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }, ], }, }, }, }; // Create a new TestMcpServer for this test const methodsTestServer = new TestMcpServer(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const methodsOas = new Oas(methodsSpec as any); const methodsOpenApiSpec = OpenApiSpec.from(methodsOas, { app: testApp().app }); // Call createResources methodsOpenApiSpec.createResources(methodsTestServer as unknown as McpServer); // Only GET should be registered as a resource const operationIds = methodsTestServer.resourceCalls.map((call) => call.id); expect(operationIds).toContain('getItem'); expect(operationIds).not.toContain('createItem'); expect(operationIds).not.toContain('updateItem'); expect(operationIds).not.toContain('patchItem'); expect(operationIds).not.toContain('deleteItem'); expect(operationIds.length).toBe(1); }); it('should register resource callbacks with the correct signature', () => { // Call the createResources method openApiSpec.createResources(testServer as unknown as McpServer); // Find the getPetById resource const getPetCall = testServer.resourceCalls.find((call) => call.id === 'getPetById'); expect(getPetCall).toBeDefined(); // Test that the callback is registered with the correct signature expect(getPetCall?.callback).toBeDefined(); // Since we've already verified getPetCall exists, we use an if guard // for type narrowing following the project's style guidelines if (getPetCall) { const { callback } = getPetCall; expect(typeof callback).toBe('function'); // Verify the callback signature by examining its length // ResourceCallbackFn should have 2 parameters (uri and args) expect(callback.length).toBe(2); } }); it('should create MCP tools from the OpenAPI spec', () => { // Call the createTools method openApiSpec.createTools(testServer as unknown as McpServer); // Verify that tools were registered expect(testServer.toolCalls.length).toBeGreaterThan(0); // Verify the tool call for the POST endpoint const createPetCall = testServer.toolCalls.find((call) => call.id === 'createPet'); expect(createPetCall).toBeDefined(); expect(createPetCall?.description).toContain('Create a pet'); // Verify that delete tool was created const deletePetCall = testServer.toolCalls.find((call) => call.id === 'deletePetById'); expect(deletePetCall).toBeDefined(); }); it('should generate correct parameter schemas for tools', () => { openApiSpec.createTools(testServer as unknown as McpServer); // Verify parameter schemas for each tool for (const call of testServer.toolCalls) { // All tools should have defined schemas expect(call.schema).toBeDefined(); switch (call.id) { case 'createPet': // Verify schema matches CreatePetSchema definition expect(call.schema).toMatchObject({ name: {}, // name should exist (details in the schema don't matter for this test) tag: {}, // tag should exist }); break; case 'deletePetById': // Verify petId parameter exists expect(call.schema).toMatchObject({ petId: {}, // petId should exist }); break; } } }); it('should apply safety hints based on HTTP method', () => { openApiSpec.createTools(testServer as unknown as McpServer); // Verify hints for specific operations for (const call of testServer.toolCalls) { // We expect hints for tool calls expect(call.hints).toBeDefined(); // Check expected safety hints based on HTTP method switch (call.id) { case 'createPet': expect(call.hints).toMatchObject({ readOnlyHint: false, idempotentHint: false }); break; case 'deletePetById': expect(call.hints).toMatchObject({ readOnlyHint: false, destructiveHint: true }); break; } } }); }); });

Latest Blog Posts

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/wycats/mcpify'

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