Skip to main content
Glama

NestJS MCP Server Module

by rekog-labs
MIT License
32,416
470
  • Apple
  • Linux
mcp-tool.e2e.spec.ts27.3 kB
import { Progress } from '@modelcontextprotocol/sdk/types.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { INestApplication, Inject, Injectable, Scope } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { z } from 'zod'; import { Context, McpTransportType, Tool } from '../src'; import { McpModule } from '../src/mcp/mcp.module'; import { createSseClient, createStdioClient, createStreamableClient, createSseClientWithElicitation, createStreamableClientWithElicitation, } from './utils'; import { REQUEST } from '@nestjs/core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { randomUUID } from 'crypto'; @Injectable() class MockUserRepository { async findByName(name: string) { return Promise.resolve({ id: 'user123', name: 'Repository User Name ' + name, orgMemberships: [ { orgId: 'org123', organization: { name: 'Repository Org', }, }, ], }); } } @Injectable() export class GreetingTool { constructor(private readonly userRepository: MockUserRepository) {} @Tool({ name: 'hello-world', description: 'A sample tool that gets the user by name', parameters: z.object({ name: z.string().default('World'), }), }) async sayHello({ name }, context: Context) { // Validate that mcpServer and mcpRequest properties exist if (!context.mcpServer) { throw new Error('mcpServer is not defined in the context'); } if (!context.mcpRequest) { throw new Error('mcpRequest is not defined in the context'); } const user = await this.userRepository.findByName(name); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 50)); await context.reportProgress({ progress: (i + 1) * 20, total: 100, } as Progress); } return { content: [ { type: 'text', text: `Hello, ${user.name}!`, }, ], }; } @Tool({ name: 'hello-world-error', description: 'A sample tool that throws an error', parameters: z.object({}), }) async sayHelloError() { throw new Error('any error'); } @Tool({ name: 'hello-world-with-annotations', description: 'A sample tool with annotations', parameters: z.object({ name: z.string().default('World'), }), annotations: { title: 'Say Hello', readOnlyHint: true, openWorldHint: false, }, }) async sayHelloWithAnnotations({ name }, context: Context) { const user = await this.userRepository.findByName(name); return { content: [ { type: 'text', text: `Hello with annotations, ${user.name}!`, }, ], }; } @Tool({ name: 'hello-world-with-meta', description: 'A sample tool with meta', parameters: z.object({ name: z.string().default('World'), }), _meta: { title: 'Say Hello', }, }) async sayHelloWithMeta({ name }, context: Context) { const user = await this.userRepository.findByName(name); return { content: [ { type: 'text', text: `Hello with annotations, ${user.name}!`, }, ], }; } } @Injectable({ scope: Scope.REQUEST }) export class GreetingToolRequestScoped { constructor(private readonly userRepository: MockUserRepository) {} @Tool({ name: 'hello-world-scoped', description: 'A sample request-scoped tool that gets the user by name', parameters: z.object({ name: z.string().default('World'), }), }) async sayHello({ name }, context: Context) { const user = await this.userRepository.findByName(name); for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 50)); await context.reportProgress({ progress: (i + 1) * 20, total: 100, } as Progress); } return { content: [ { type: 'text', text: `Hello, ${user.name}!`, }, ], }; } } @Injectable({ scope: Scope.REQUEST }) export class ToolRequestScoped { constructor(@Inject(REQUEST) private request: Request) {} @Tool({ name: 'get-request-scoped', description: 'A sample tool that gets a header from the request', parameters: z.object({}), }) async getRequest() { return { content: [ { type: 'text', text: this.request.headers['any-header'] ?? 'No header found', }, ], }; } } @Injectable() class OutputSchemaTool { constructor() {} @Tool({ name: 'output-schema-tool', description: 'A tool to test outputSchema', parameters: z.object({ input: z.string().describe('Example input'), }), outputSchema: z.object({ result: z.string().describe('Example result'), }), }) async execute({ input }) { return { content: [ { type: 'text', text: JSON.stringify({ result: input }), }, ], }; } } @Injectable() class InvalidOutputSchemaTool { @Tool({ name: 'invalid-output-schema-tool', description: 'Returns an object that does not match its outputSchema', parameters: z.object({}), outputSchema: z.object({ foo: z.string(), }), }) async execute() { return { bar: 123 }; } } @Injectable() class ValidationTestTool { @Tool({ name: 'validation-test-tool', description: 'A tool to test input validation with required parameters', parameters: z.object({ requiredString: z.string(), requiredNumber: z.number(), optionalParam: z.string().optional(), }), }) async execute({ requiredString, requiredNumber, optionalParam }) { return { content: [ { type: 'text', text: `Received: ${requiredString}, ${requiredNumber}, ${optionalParam}`, }, ], }; } } @Injectable() class NotMcpCompliantGreetingTool { @Tool({ name: 'not-mcp-greeting', description: 'Returns a plain object, not MCP-compliant', parameters: z.object({ name: z.string().default('World') }), }) async greet({ name }) { return { greeting: `Hello, ${name}!` }; } } @Injectable() class NotMcpCompliantStructuredGreetingTool { @Tool({ name: 'not-mcp-structured-greeting', description: 'Returns a plain object with outputSchema', parameters: z.object({ name: z.string().default('World') }), outputSchema: z.object({ greeting: z.string() }), }) async greet({ name }) { return { greeting: `Hello, ${name}!` }; } } @Injectable() export class GreetingToolWithElicitation { @Tool({ name: 'hello-world-elicitation', description: 'Returns a greeting and simulates a long operation with progress updates', parameters: z.object({ name: z.string().default('World'), }), }) async sayHelloElicitation({ name }, context: Context, request: Request) { try { const res = context.mcpServer.server.getClientCapabilities(); if (!res?.elicitation) { return { content: [ { type: 'text', text: 'Elicitation is not supported by the client. Thus this tool cannot be used.', }, ], }; } const response = await context.mcpServer.server.elicitInput({ message: 'Please provide your name', requestedSchema: { type: 'object', properties: { surname: { type: 'string', description: 'Your surname' }, }, }, }); let fullName = ''; switch (response.action) { case 'accept': { const surname = response?.content?.surname as string; fullName = `${name} ${surname}`; break; } case 'decline': case 'cancel': fullName = name; break; default: fullName = name; } return { content: [{ type: 'text', text: `Hello, ${fullName}!` }], }; } catch (error) { return { content: [{ type: 'text', text: `Error: ${error.message}` }], }; } } } describe('E2E: MCP ToolServer', () => { let app: INestApplication; let statelessApp: INestApplication; let statefulServerPort: number; let statelessServerPort: number; // Set timeout for all tests in this describe block to 15000ms jest.setTimeout(15000); beforeAll(async () => { // Create stateful server (original) const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ McpModule.forRoot({ name: 'test-mcp-server', version: '0.0.1', guards: [], streamableHttp: { enableJsonResponse: false, sessionIdGenerator: () => randomUUID(), statelessMode: false, }, }), ], providers: [ GreetingTool, GreetingToolRequestScoped, MockUserRepository, ToolRequestScoped, OutputSchemaTool, NotMcpCompliantGreetingTool, NotMcpCompliantStructuredGreetingTool, InvalidOutputSchemaTool, ValidationTestTool, GreetingToolWithElicitation, ], }).compile(); app = moduleFixture.createNestApplication(); await app.listen(0); const server = app.getHttpServer(); if (!server.address()) { throw new Error('Server address not found after listen'); } statefulServerPort = (server.address() as import('net').AddressInfo).port; // Create stateless server const statelessModuleFixture: TestingModule = await Test.createTestingModule({ imports: [ McpModule.forRoot({ name: 'test-stateless-mcp-server', version: '0.0.1', guards: [], transport: McpTransportType.STREAMABLE_HTTP, streamableHttp: { enableJsonResponse: true, sessionIdGenerator: undefined, statelessMode: true, }, }), ], providers: [ GreetingTool, GreetingToolRequestScoped, MockUserRepository, ToolRequestScoped, OutputSchemaTool, NotMcpCompliantGreetingTool, NotMcpCompliantStructuredGreetingTool, InvalidOutputSchemaTool, ValidationTestTool, GreetingToolWithElicitation, ], }).compile(); statelessApp = statelessModuleFixture.createNestApplication(); await statelessApp.listen(0); const statelessServer = statelessApp.getHttpServer(); if (!statelessServer.address()) { throw new Error('Stateless server address not found after listen'); } statelessServerPort = ( statelessServer.address() as import('net').AddressInfo ).port; }); afterAll(async () => { await app.close(); await statelessApp.close(); }); const runClientTests = ( clientType: 'http+sse' | 'streamable http' | 'stdio', clientCreator: (port: number, options?: any) => Promise<Client>, requestScopedHeaderValue: string, stateless = false, ) => { describe(`using ${clientType} client (${clientCreator.name})`, () => { let port: number; beforeAll(async () => { port = stateless ? statelessServerPort : statefulServerPort; }); it('should list tools', async () => { const client = await clientCreator(port); try { const tools = await client.listTools(); expect(tools.tools.length).toBeGreaterThan(0); expect( tools.tools.find((t) => t.name === 'hello-world'), ).toBeDefined(); expect( tools.tools.find((t) => t.name === 'hello-world-scoped'), ).toBeDefined(); expect( tools.tools.find((t) => t.name === 'get-request-scoped'), ).toBeDefined(); expect( tools.tools.find((t) => t.name === 'output-schema-tool'), ).toBeDefined(); expect( tools.tools.find((t) => t.name === 'not-mcp-greeting'), ).toBeDefined(); expect( tools.tools.find((t) => t.name === 'not-mcp-structured-greeting'), ).toBeDefined(); expect( tools.tools.find((t) => t.name === 'invalid-output-schema-tool'), ).toBeDefined(); } finally { await client.close(); } }); it('should list tools with outputSchema', async () => { const client = await clientCreator(port); try { const tools = await client.listTools(); expect(tools.tools.length).toBeGreaterThan(0); const outputSchemaTool = tools.tools.find( (t) => t.name === 'output-schema-tool', ); expect(outputSchemaTool).toBeDefined(); expect(outputSchemaTool?.outputSchema).toBeDefined(); expect(outputSchemaTool?.outputSchema).toHaveProperty( 'properties.result', ); } finally { await client.close(); } }); it('should list tools without outputSchema', async () => { const client = await clientCreator(port); try { const tools = await client.listTools(); expect(tools.tools.length).toBeGreaterThan(0); const schemaTool = tools.tools.find((t) => t.name === 'hello-world'); expect(schemaTool?.outputSchema).not.toBeDefined(); } finally { await client.close(); } }); it('should list tools with annotations', async () => { const client = await clientCreator(port); try { const tools = await client.listTools(); expect(tools.tools.length).toBeGreaterThan(0); const annotatedTool = tools.tools.find( (t) => t.name === 'hello-world-with-annotations', ); expect(annotatedTool).toBeDefined(); expect(annotatedTool?.annotations).toBeDefined(); expect(annotatedTool?.annotations?.title).toBe('Say Hello'); expect(annotatedTool?.annotations?.readOnlyHint).toBe(true); expect(annotatedTool?.annotations?.openWorldHint).toBe(false); } finally { await client.close(); } }); it('should list tools with meta', async () => { const client = await clientCreator(port); try { const tools = await client.listTools(); expect(tools.tools.length).toBeGreaterThan(0); const metaTool = tools.tools.find((t) => t.name === 'hello-world-with-meta'); expect(metaTool).toBeDefined(); expect(metaTool!._meta).toBeDefined(); expect(metaTool!._meta?.title).toBe('Say Hello'); } finally { await client.close(); } }); it.each([{ tool: 'hello-world' }, { tool: 'hello-world-scoped' }])( 'should call the tool $tool and receive results', async ({ tool }) => { const client = await clientCreator(port); try { let progressCount = 1; const result: any = await client.callTool( { name: tool, arguments: { name: 'userRepo123' } }, undefined, { onprogress: (progress: Progress) => { expect(progress.progress).toBeGreaterThan(0); expect(progress.total).toBe(100); progressCount++; }, }, ); if (clientType != 'stdio' && !stateless) { // stdio has no support for progress expect(progressCount).toBe(5); } expect(result.content[0].type).toBe('text'); expect(result.content[0].text).toContain( 'Hello, Repository User Name userRepo123!', ); } finally { await client.close(); } }, ); it('should call the tool get-request-scoped and receive header', async () => { const client = await clientCreator(port, { requestInit: { headers: { 'any-header': requestScopedHeaderValue }, }, }); try { const result: any = await client.callTool({ name: 'get-request-scoped', arguments: {}, }); expect(result.content[0].type).toBe('text'); expect(result.content[0].text).toContain(requestScopedHeaderValue); } finally { await client.close(); } }); it('should reject invalid arguments for hello-world', async () => { const client = await clientCreator(port); try { await client.callTool({ name: 'hello-world', arguments: { name: 123 } as any, // Wrong type for 'name' }); } catch (error) { expect(error).toBeDefined(); expect(error.message).toContain('Expected string, received number'); } await client.close(); }); it('should reject missing arguments for hello-world', async () => { const client = await clientCreator(port); try { await client.callTool({ name: 'hello-world', arguments: {} as any, }); } catch (error) { expect(error).toBeDefined(); expect(error.message).toContain('Required'); } await client.close(); }); it('should test input validation with truly required parameters', async () => { const client = await clientCreator(port); try { await client.callTool({ name: 'validation-test-tool', arguments: {}, // Missing both required parameters }); // If we reach here, validation is NOT working expect(true).toBe(false); // Force failure } catch (error) { expect(error).toBeDefined(); console.log('Validation error:', error.message); // Expect MCP InvalidParams error expect( error.message.includes('Invalid parameters') || error.message.includes('Required') || error.message.includes('string') || error.message.includes('number') || error.code === -32602, // ErrorCode.InvalidParams ).toBe(true); } await client.close(); }); it('should test input validation with wrong types', async () => { const client = await clientCreator(port); try { await client.callTool({ name: 'validation-test-tool', arguments: { requiredString: 123, // Wrong type requiredNumber: 'not a number', // Wrong type }, }); // If we reach here, validation is NOT working expect(true).toBe(false); // Force failure } catch (error) { expect(error).toBeDefined(); console.log('Type validation error:', error.message); // Expect MCP InvalidParams error expect( error.message.includes('Invalid parameters') || error.message.includes('Expected string') || error.message.includes('Expected number') || error.code === -32602, // ErrorCode.InvalidParams ).toBe(true); } await client.close(); }); it('should call the tool and receive an error', async () => { const client = await clientCreator(port); try { const result: any = await client.callTool({ name: 'hello-world-error', arguments: {}, }); // Both clients should return the standardized error format expect(result).toEqual({ content: [{ type: 'text', text: 'any error' }], isError: true, }); } finally { await client.close(); } }); it('should transform non-MCP-compliant response into MCP-compliant payload', async () => { const client = await clientCreator(port); try { const result: any = await client.callTool({ name: 'not-mcp-greeting', arguments: { name: 'TestUser' }, }); expect(result).toHaveProperty('content'); expect(Array.isArray(result.content)).toBe(true); expect(result.content[0].type).toBe('text'); expect(result.content[0].text).toContain('greeting'); expect(result.content[0].text).toContain('Hello, TestUser!'); } finally { await client.close(); } }); it('should transform non-MCP-compliant response with outputSchema into MCP-compliant payload with structuredContent', async () => { const client = await clientCreator(port); try { const result: any = await client.callTool({ name: 'not-mcp-structured-greeting', arguments: { name: 'TestUser' }, }); expect(result).toHaveProperty('structuredContent'); expect(result.structuredContent).toEqual({ greeting: 'Hello, TestUser!', }); expect(result).toHaveProperty('content'); expect(Array.isArray(result.content)).toBe(true); expect(result.content[0].type).toBe('text'); expect(result.content[0].text).toContain('greeting'); expect(result.content[0].text).toContain('Hello, TestUser!'); } finally { await client.close(); } }); it('should throw an MCP error if tool result does not match outputSchema', async () => { const client = await clientCreator(port); try { await client.callTool({ name: 'invalid-output-schema-tool', arguments: {}, }); // If we reach here, validation is NOT working expect(true).toBe(false); // Force failure } catch (error) { expect(error).toBeDefined(); expect(error.message).toContain('Tool result does not match'); expect(error.code).toBe(-32603); // ErrorCode.InternalError } finally { await client.close(); } }); }); }; // Elicitation tests const runElicitationTests = ( clientType: 'http+sse' | 'streamable http', clientCreator: (port: number, options?: any) => Promise<Client>, stateless = false, ) => { describe(`Elicitation tests using ${clientType} client`, () => { let port: number; beforeAll(async () => { port = stateless ? statelessServerPort : statefulServerPort; }); it('should handle elicitation in hello-world-elicitation tool', async () => { const client = await clientCreator(port); try { const result: any = await client.callTool({ name: 'hello-world-elicitation', arguments: { name: 'TestUser' }, }); expect(result.content).toBeDefined(); expect(result.content[0].type).toBe('text'); expect(result.content[0].text).toContain( 'Hello, TestUser TestSurname!', ); } finally { await client.close(); } }); it('should list hello-world-elicitation tool', async () => { const client = await clientCreator(port); try { const tools = await client.listTools(); const elicitationTool = tools.tools.find( (t) => t.name === 'hello-world-elicitation', ); expect(elicitationTool).toBeDefined(); expect(elicitationTool?.description).toContain('progress updates'); } finally { await client.close(); } }); it('should handle declined elicitation gracefully', async () => { // Create a client that declines elicitation requests const client = await clientCreator(port); // Override the elicit request handler to decline client.setRequestHandler(ElicitRequestSchema, () => ({ action: 'decline', })); try { const result: any = await client.callTool({ name: 'hello-world-elicitation', arguments: { name: 'TestUser' }, }); expect(result.content).toBeDefined(); expect(result.content[0].type).toBe('text'); // Should use default surname when elicitation is declined expect(result.content[0].text).toContain('Hello, TestUser!'); } finally { await client.close(); } }); it('should handle cancelled elicitation gracefully', async () => { // Create a client that cancels elicitation requests const client = await clientCreator(port); // Override the elicit request handler to cancel client.setRequestHandler(ElicitRequestSchema, () => ({ action: 'cancel', })); try { const result: any = await client.callTool({ name: 'hello-world-elicitation', arguments: { name: 'TestUser' }, }); expect(result.content).toBeDefined(); expect(result.content[0].type).toBe('text'); // Should use default surname when elicitation is cancelled expect(result.content[0].text).toContain('Hello, TestUser!'); } finally { await client.close(); } }); }); }; // Run elicitation tests (supported only by stateful servers) runElicitationTests('http+sse', createSseClientWithElicitation); runElicitationTests('streamable http', createStreamableClientWithElicitation); // Test elicitation with non-elicitation clients describe('Elicitation with non-elicitation clients', () => { it('should handle elicitation tool call gracefully when client lacks elicitation capability', async () => { // Use a regular client without elicitation capabilities const client = await createSseClient(statefulServerPort); try { const result: any = await client.callTool({ name: 'hello-world-elicitation', arguments: { name: 'TestUser' }, }); expect(result.content).toBeDefined(); expect(result.content[0].type).toBe('text'); // Should either error or fall back to default behavior expect( result.content[0].text.includes( 'Elicitation is not supported by the client. Thus this tool cannot be used.', ), ).toBe(true); } finally { await client.close(); } }); }); // Run tests using the HTTP+SSE MCP client runClientTests('http+sse', createSseClient, 'any-value'); // Run tests using the [Stateful] Streamable HTTP MCP client runClientTests('streamable http', createStreamableClient, 'streamable-value'); // Run tests using the [Stateless] Streamable HTTP MCP client runClientTests( 'streamable http', createStreamableClient, 'stateless-streamable-value', true, ); runClientTests( 'stdio', () => createStdioClient({ serverScriptPath: 'tests/sample/stdio-server.ts' }), 'No header (stdio)', ); });

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/rekog-labs/MCP-Nest'

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