UNPKG

@toolprint/mcp-graphql-forge

Version:

MCP server that exposes GraphQL APIs to AI tools through automatic schema introspection and tool generation

437 lines (364 loc) 14.2 kB
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createServer } from 'http'; import { GraphQLClient } from 'graphql-request'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { generateMCPToolsFromSchema, MCPTool } from '../tool-generator.js'; import { introspectGraphQLSchema } from '../introspect.js'; import { mockIntrospectionResult } from './fixtures/introspection-result.js'; // Enhanced mock GraphQL server that handles actual queries function createEnhancedMockGraphQLServer(port: number = 4003) { const mockData = { users: [ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'ADMIN' }, { id: '2', name: 'Bob', email: 'bob@example.com', role: 'USER' } ], posts: [ { id: '1', title: 'Hello World', content: 'First post', authorId: '1' }, { id: '2', title: 'GraphQL is Great', content: 'Second post', authorId: '2' } ] }; const server = createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } if (req.method !== 'POST') { res.writeHead(405); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { try { const { query, variables } = JSON.parse(body); // Handle introspection if (query.includes('__schema')) { res.writeHead(200); res.end(JSON.stringify({ data: mockIntrospectionResult })); return; } // Handle actual queries let responseData = {}; // Parse the query to determine what to return if (query.includes('query userOperation')) { const userId = variables?.id; const user = mockData.users.find(u => u.id === userId); responseData = { user }; } else if (query.includes('query usersOperation')) { const limit = variables?.limit || mockData.users.length; const offset = variables?.offset || 0; const users = mockData.users.slice(offset, offset + limit); responseData = { users }; } else if (query.includes('mutation createUserOperation')) { const newUser = { id: String(mockData.users.length + 1), ...variables?.input }; mockData.users.push(newUser); responseData = { createUser: newUser }; } res.writeHead(200); res.end(JSON.stringify({ data: responseData })); } catch (error) { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid request', details: error instanceof Error ? error.message : 'Unknown error' })); } }); }); return { server, start: () => new Promise<void>((resolve) => { server.listen(port, () => { resolve(); }); }), stop: () => new Promise<void>((resolve) => { server.close(() => { resolve(); }); }) }; } // Mock MCP Server class for testing class TestGraphQLMCPServer { private server: Server; private graphqlClient: GraphQLClient; private tools: MCPTool[] = []; constructor(graphqlEndpoint: string) { this.server = new Server( { name: 'test-fast-mcp-graphql', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); this.graphqlClient = new GraphQLClient(graphqlEndpoint); this.setupHandlers(); } async loadTools() { const introspectionResult = await introspectGraphQLSchema({ endpoint: this.graphqlClient.url }); this.tools = generateMCPToolsFromSchema(introspectionResult); } private setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: this.tools }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { const tool = this.tools.find(t => t.name === name); if (!tool || !tool._graphql) { throw new Error(`Unknown tool: ${name}`); } const query = this.buildGraphQLOperation(tool._graphql, args); const result = await this.graphqlClient.request(query, args); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { content: [ { type: 'text', text: `Error executing ${name}: ${errorMessage}` } ], isError: true }; } }); } private getGraphQLVariableType(graphqlType: any): string { if (graphqlType.kind === 'NON_NULL') { return `${this.getGraphQLVariableType(graphqlType.ofType)}!`; } if (graphqlType.kind === 'LIST') { return `[${this.getGraphQLVariableType(graphqlType.ofType)}]`; } return graphqlType.name; } private buildGraphQLOperation(graphqlInfo: NonNullable<MCPTool['_graphql']>, variables: any): string { const { fieldName, operationType, args, fieldSelection } = graphqlInfo; const variableDeclarations = args .map(arg => `$${arg.name}: ${this.getGraphQLVariableType(arg.type)}`) .join(', '); const variableUsage = args .map(arg => `${arg.name}: $${arg.name}`) .join(', '); const operation = ` ${operationType} ${fieldName}Operation${variableDeclarations ? `(${variableDeclarations})` : ''} { ${fieldName}${variableUsage ? `(${variableUsage})` : ''} ${fieldSelection} } `; return operation.trim(); } // Test helper methods async callTool(toolName: string, args: any = {}) { const request = { method: 'tools/call' as const, params: { name: toolName, arguments: args } }; return await this.server.request(request); } async listTools() { const request = { method: 'tools/list' as const, params: {} }; return await this.server.request(request); } getTools() { return this.tools; } } describe('MCP Server Execution Tests', () => { const mockServer = createEnhancedMockGraphQLServer(4003); let mcpServer: TestGraphQLMCPServer; beforeAll(async () => { await mockServer.start(); mcpServer = new TestGraphQLMCPServer('http://localhost:4003/graphql'); await mcpServer.loadTools(); }); afterAll(async () => { await mockServer.stop(); }); describe('Tool Generation and Structure', () => { it('should generate tools with proper GraphQL metadata', async () => { const tools = mcpServer.getTools(); expect(tools.length).toBeGreaterThan(0); // Check that tools have _graphql metadata tools.forEach(tool => { expect(tool._graphql).toBeDefined(); expect(tool._graphql?.fieldName).toBeDefined(); expect(tool._graphql?.operationType).toMatch(/^(query|mutation)$/); expect(tool._graphql?.args).toBeDefined(); expect(tool._graphql?.fieldSelection).toBeDefined(); }); }); it('should generate proper field selections for complex types', async () => { const tools = mcpServer.getTools(); const userTool = tools.find(t => t.name === 'query_user'); expect(userTool?._graphql?.fieldSelection).toContain('id'); expect(userTool?._graphql?.fieldSelection).toContain('name'); expect(userTool?._graphql?.fieldSelection).toContain('email'); expect(userTool?._graphql?.fieldSelection).toContain('role'); }); }); describe('Query Tool Execution', () => { it('should execute query_user tool successfully', async () => { const result = await mcpServer.callTool('query_user', { id: '1' }); expect(result).toBeDefined(); expect(result.content).toBeDefined(); expect(result.content[0].type).toBe('text'); const data = JSON.parse(result.content[0].text); expect(data.user).toBeDefined(); expect(data.user.id).toBe('1'); expect(data.user.name).toBe('Alice'); expect(data.user.email).toBe('alice@example.com'); }); it('should execute query_users tool successfully', async () => { const result = await mcpServer.callTool('query_users', { limit: 2, offset: 0 }); expect(result).toBeDefined(); expect(result.content).toBeDefined(); expect(result.content[0].type).toBe('text'); const data = JSON.parse(result.content[0].text); expect(data.users).toBeDefined(); expect(Array.isArray(data.users)).toBe(true); expect(data.users.length).toBe(2); expect(data.users[0].name).toBe('Alice'); expect(data.users[1].name).toBe('Bob'); }); it('should handle query_users with no parameters', async () => { const result = await mcpServer.callTool('query_users', {}); expect(result).toBeDefined(); expect(result.content).toBeDefined(); const data = JSON.parse(result.content[0].text); expect(data.users).toBeDefined(); expect(Array.isArray(data.users)).toBe(true); }); }); describe('Mutation Tool Execution', () => { it('should execute mutation_createUser tool successfully', async () => { const newUserInput = { name: 'Charlie', email: 'charlie@example.com', role: 'USER' }; const result = await mcpServer.callTool('mutation_createUser', { input: newUserInput }); expect(result).toBeDefined(); expect(result.content).toBeDefined(); expect(result.content[0].type).toBe('text'); const data = JSON.parse(result.content[0].text); expect(data.createUser).toBeDefined(); expect(data.createUser.name).toBe('Charlie'); expect(data.createUser.email).toBe('charlie@example.com'); expect(data.createUser.role).toBe('USER'); expect(data.createUser.id).toBeDefined(); }); }); describe('Error Handling', () => { it('should handle unknown tool names gracefully', async () => { const result = await mcpServer.callTool('unknown_tool', {}); expect(result).toBeDefined(); expect(result.content).toBeDefined(); expect(result.content[0].text).toContain('Unknown tool: unknown_tool'); expect(result.isError).toBe(true); }); it('should handle missing required parameters', async () => { // Try to call query_user without required id parameter const result = await mcpServer.callTool('query_user', {}); expect(result).toBeDefined(); expect(result.content).toBeDefined(); // Should either error or return null user const data = JSON.parse(result.content[0].text); expect(data.user).toBeNull(); }); }); describe('GraphQL Query Generation', () => { it('should generate valid GraphQL queries with proper types', async () => { const tools = mcpServer.getTools(); // Check user query tool const userTool = tools.find(t => t.name === 'query_user'); expect(userTool?._graphql?.args[0].name).toBe('id'); // Check users query tool const usersTool = tools.find(t => t.name === 'query_users'); expect(usersTool?._graphql?.args.some(arg => arg.name === 'limit')).toBe(true); expect(usersTool?._graphql?.args.some(arg => arg.name === 'offset')).toBe(true); // Check createUser mutation tool const createUserTool = tools.find(t => t.name === 'mutation_createUser'); expect(createUserTool?._graphql?.args[0].name).toBe('input'); }); it('should include all available fields in field selections', async () => { const tools = mcpServer.getTools(); const userTool = tools.find(t => t.name === 'query_user'); const fieldSelection = userTool?._graphql?.fieldSelection || ''; // Should include all User fields expect(fieldSelection).toContain('id'); expect(fieldSelection).toContain('name'); expect(fieldSelection).toContain('email'); expect(fieldSelection).toContain('role'); // Should be properly formatted expect(fieldSelection).toMatch(/\{[\s\S]*\}/); // Contains braces }); }); describe('Tool List Functionality', () => { it('should list all available tools', async () => { const result = await mcpServer.listTools(); expect(result).toBeDefined(); expect(result.tools).toBeDefined(); expect(Array.isArray(result.tools)).toBe(true); expect(result.tools.length).toBeGreaterThan(0); // Should have both query and mutation tools const queryTools = result.tools.filter((t: any) => t.name.startsWith('query_')); const mutationTools = result.tools.filter((t: any) => t.name.startsWith('mutation_')); expect(queryTools.length).toBeGreaterThan(0); expect(mutationTools.length).toBeGreaterThan(0); }); it('should provide proper tool schemas', async () => { const result = await mcpServer.listTools(); result.tools.forEach((tool: any) => { expect(tool.name).toBeDefined(); expect(tool.description).toBeDefined(); expect(tool.inputSchema).toBeDefined(); expect(tool.inputSchema.type).toBe('object'); expect(tool.inputSchema.properties).toBeDefined(); }); }); }); });