mcp-fastify-adapter.e2e.spec.ts•14.5 kB
import { Progress } from '@modelcontextprotocol/sdk/types.js';
import { INestApplication, Injectable, Scope } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { z } from 'zod';
import { Context, McpModule, McpTransportType, Tool } from '../src';
import { createStreamableClient } from './utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { randomUUID } from 'crypto';
import {
CallToolRequest,
CallToolResultSchema,
ListToolsRequest,
ListToolsResultSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { FastifyAdapter } from '@nestjs/platform-fastify';
@Injectable()
class MockUserRepository {
async findByName(name: string) {
return Promise.resolve({
id: 'user123',
name: 'Fastify User ' + name,
framework: 'fastify',
});
}
}
@Injectable()
export class FastifyTestTool {
constructor(private readonly userRepository: MockUserRepository) {}
@Tool({
name: 'fastify-hello-world',
description: 'A test tool to verify Fastify adapter works',
parameters: z.object({
name: z.string().default('World'),
}),
})
async sayHello({ name }: { name: string }, context: Context) {
// Validate that context 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);
// Report progress to test streaming works
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
await context.reportProgress({
progress: (i + 1) * 33,
total: 100,
} as Progress);
}
return {
content: [
{
type: 'text',
text: `Hello from ${user.framework}, ${user.name}!`,
},
],
};
}
@Tool({
name: 'framework-detector',
description: 'Detects which HTTP framework is being used',
parameters: z.object({}),
})
async detectFramework() {
// For testing purposes, we'll identify the framework based on the tool behavior
// In a real implementation, the adapter factory would handle this automatically
return {
content: [
{
type: 'text',
text: `Framework detection test - adapter working correctly`,
},
],
};
}
}
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedTool {
@Tool({
name: 'request-scope-test',
description: 'Tests request scoping with Fastify',
parameters: z.object({
testId: z.string().default('test-123'),
}),
})
async testRequestScope({ testId }: { testId: string }) {
// Generate a unique ID to verify request scoping
const uniqueId = randomUUID();
return {
content: [
{
type: 'text',
text: `Request-scoped response: testId=${testId}, uniqueId=${uniqueId}`,
},
],
};
}
}
describe('E2E: Fastify HTTP Adapter Support', () => {
let expressApp: INestApplication;
let fastifyApp: INestApplication;
let expressPort: number;
let fastifyPort: number;
// Set timeout for all tests in this describe block
jest.setTimeout(20000);
beforeAll(async () => {
// Create Express-based server (control group)
const expressModuleFixture: TestingModule = await Test.createTestingModule({
imports: [
McpModule.forRoot({
name: 'test-express-mcp-server',
version: '0.0.1',
transport: McpTransportType.STREAMABLE_HTTP,
streamableHttp: {
enableJsonResponse: false,
sessionIdGenerator: () => randomUUID(),
statelessMode: false,
},
}),
],
providers: [FastifyTestTool, MockUserRepository, RequestScopedTool],
}).compile();
expressApp = expressModuleFixture.createNestApplication();
await expressApp.listen(0);
const expressServer = expressApp.getHttpServer();
if (!expressServer.address()) {
throw new Error('Express server address not found after listen');
}
expressPort = (expressServer.address() as import('net').AddressInfo).port;
// Create Fastify-based server (test subject)
const fastifyModuleFixture: TestingModule = await Test.createTestingModule({
imports: [
McpModule.forRoot({
name: 'test-fastify-mcp-server',
version: '0.0.1',
transport: McpTransportType.STREAMABLE_HTTP,
streamableHttp: {
enableJsonResponse: false,
sessionIdGenerator: () => randomUUID(),
statelessMode: false,
},
}),
],
providers: [FastifyTestTool, MockUserRepository, RequestScopedTool],
}).compile();
const fastifyAdapter = new FastifyAdapter();
fastifyApp = fastifyModuleFixture.createNestApplication(fastifyAdapter);
await fastifyApp.listen(0, '0.0.0.0');
const fastifyServer = fastifyApp.getHttpServer();
if (!fastifyServer.address()) {
throw new Error('Fastify server address not found after listen');
}
fastifyPort = (fastifyServer.address() as import('net').AddressInfo).port;
});
afterAll(async () => {
if (expressApp) {
await expressApp.close();
}
if (fastifyApp) {
await fastifyApp.close();
}
});
describe('Express Server (Control)', () => {
let client: Client;
beforeEach(async () => {
if (!expressPort) {
throw new Error('Express server not available');
}
client = await createStreamableClient(expressPort);
});
afterEach(async () => {
if (client) {
await client.close();
}
});
it('should connect and list tools', async () => {
const toolsRequest: ListToolsRequest = {
method: 'tools/list',
params: {},
};
const toolsResult = await client.request(
toolsRequest,
ListToolsResultSchema,
);
expect(toolsResult.tools).toBeDefined();
expect(toolsResult.tools.length).toBeGreaterThan(0);
const toolNames = toolsResult.tools.map((tool) => tool.name);
expect(toolNames).toContain('fastify-hello-world');
expect(toolNames).toContain('framework-detector');
expect(toolNames).toContain('request-scope-test');
});
it('should execute tools with Express', async () => {
const greetRequest: CallToolRequest = {
method: 'tools/call',
params: {
name: 'fastify-hello-world',
arguments: { name: 'Express Test' },
},
};
const result = await client.request(greetRequest, CallToolResultSchema);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain(
'Hello from fastify, Fastify User Express Test!',
);
});
it('should detect Express framework', async () => {
const detectRequest: CallToolRequest = {
method: 'tools/call',
params: {
name: 'framework-detector',
arguments: {},
},
};
const result = await client.request(detectRequest, CallToolResultSchema);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain('adapter working correctly');
});
});
describe('Fastify Server (Test Subject)', () => {
let client: Client;
beforeEach(async () => {
if (!fastifyPort) {
throw new Error(
'Fastify server not available - install @nestjs/platform-fastify to run these tests',
);
}
client = await createStreamableClient(fastifyPort);
});
afterEach(async () => {
if (client) {
await client.close();
}
});
it('should connect and list tools with Fastify', async () => {
const toolsRequest: ListToolsRequest = {
method: 'tools/list',
params: {},
};
const toolsResult = await client.request(
toolsRequest,
ListToolsResultSchema,
);
expect(toolsResult.tools).toBeDefined();
expect(toolsResult.tools.length).toBeGreaterThan(0);
const toolNames = toolsResult.tools.map((tool) => tool.name);
expect(toolNames).toContain('fastify-hello-world');
expect(toolNames).toContain('framework-detector');
expect(toolNames).toContain('request-scope-test');
});
it('should execute tools with Fastify adapter', async () => {
const greetRequest: CallToolRequest = {
method: 'tools/call',
params: {
name: 'fastify-hello-world',
arguments: { name: 'Fastify Test' },
},
};
let progressReports = 0;
const result = await client.request(greetRequest, CallToolResultSchema, {
onprogress: (progress) => {
progressReports++;
expect(progress.progress).toBeGreaterThan(0);
expect(progress.total).toBe(100);
},
});
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain(
'Hello from fastify, Fastify User Fastify Test!',
);
expect(progressReports).toBeGreaterThan(0); // Verify progress reporting works
});
it('should detect Fastify framework', async () => {
const detectRequest: CallToolRequest = {
method: 'tools/call',
params: {
name: 'framework-detector',
arguments: {},
},
};
const result = await client.request(detectRequest, CallToolResultSchema);
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain('adapter working correctly');
});
it('should handle request scoping correctly', async () => {
const testId1 = 'test-1';
const testId2 = 'test-2';
// Make two requests with different test IDs
const [result1, result2] = await Promise.all([
client.request(
{
method: 'tools/call',
params: {
name: 'request-scope-test',
arguments: { testId: testId1 },
},
},
CallToolResultSchema,
),
client.request(
{
method: 'tools/call',
params: {
name: 'request-scope-test',
arguments: { testId: testId2 },
},
},
CallToolResultSchema,
),
]);
expect(result1.content[0].text).toContain(`testId=${testId1}`);
expect(result2.content[0].text).toContain(`testId=${testId2}`);
// Extract unique IDs to verify they're different (proper request scoping)
const text1 = result1.content[0].text as string;
const text2 = result2.content[0].text as string;
const uniqueId1 = text1.match(/uniqueId=([^,\s]+)/)?.[1];
const uniqueId2 = text2.match(/uniqueId=([^,\s]+)/)?.[1];
expect(uniqueId1).toBeDefined();
expect(uniqueId2).toBeDefined();
expect(uniqueId1).not.toBe(uniqueId2); // Different requests should have different UUIDs
});
it('should handle errors gracefully', async () => {
const invalidRequest: CallToolRequest = {
method: 'tools/call',
params: {
name: 'non-existent-tool',
arguments: {},
},
};
await expect(
client.request(invalidRequest, CallToolResultSchema),
).rejects.toThrow();
});
});
describe('Framework Compatibility', () => {
it('should produce identical tool results regardless of framework', async () => {
if (!expressPort || !fastifyPort) {
console.warn(
'Skipping compatibility test - both servers not available',
);
return;
}
const expressClient = await createStreamableClient(expressPort);
const fastifyClient = await createStreamableClient(fastifyPort);
try {
// Test the same tool on both frameworks
const testArgs = { name: 'Compatibility Test' };
const [expressResult, fastifyResult] = await Promise.all([
expressClient.request(
{
method: 'tools/call',
params: {
name: 'fastify-hello-world',
arguments: testArgs,
},
},
CallToolResultSchema,
),
fastifyClient.request(
{
method: 'tools/call',
params: {
name: 'fastify-hello-world',
arguments: testArgs,
},
},
CallToolResultSchema,
),
]);
// Both should return the same type of response structure
expect(expressResult.content).toBeDefined();
expect(fastifyResult.content).toBeDefined();
expect(expressResult.content.length).toBe(fastifyResult.content.length);
// Both should contain the expected content
expect(expressResult.content[0].text).toContain('Compatibility Test');
expect(fastifyResult.content[0].text).toContain('Compatibility Test');
} finally {
await expressClient.close();
await fastifyClient.close();
}
});
it('should list identical tools on both frameworks', async () => {
if (!expressPort || !fastifyPort) {
console.warn(
'Skipping tools list compatibility test - both servers not available',
);
return;
}
const expressClient = await createStreamableClient(expressPort);
const fastifyClient = await createStreamableClient(fastifyPort);
try {
const [expressTools, fastifyTools] = await Promise.all([
expressClient.request(
{
method: 'tools/list',
params: {},
},
ListToolsResultSchema,
),
fastifyClient.request(
{
method: 'tools/list',
params: {},
},
ListToolsResultSchema,
),
]);
// Both should have the same tools available
expect(expressTools.tools.length).toBe(fastifyTools.tools.length);
const expressToolNames = expressTools.tools.map((t) => t.name).sort();
const fastifyToolNames = fastifyTools.tools.map((t) => t.name).sort();
expect(expressToolNames).toEqual(fastifyToolNames);
} finally {
await expressClient.close();
await fastifyClient.close();
}
});
});
});