diff --git a/eslint.config.js b/eslint.config.js index 90f1802..f5c0f71 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,10 +16,12 @@ export default [ { files: ['**/*.ts'], rules: { + curly: [2, 'all'], 'no-unused-vars': 0, 'no-undef': 0, 'import/no-unresolved': 0, 'jsdoc/require-returns': 0, + 'jsdoc/require-returns-type': 0, 'jsdoc/require-param-type': 0 } }, @@ -63,9 +65,14 @@ export default [ // Test files { - files: ['**/*.test.ts'], + files: [ + '**/*.test.ts', + 'tests/**/*.ts', + '**/__tests__/**/*test.ts' + ], rules: { '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/ban-ts-comment': 1, 'no-sparse-arrays': 0 } } diff --git a/jest.config.ts b/jest.config.ts index 85ec094..e572390 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,12 +1,8 @@ -export default { +const baseConfig = { extensionsToTreatAsEsm: ['.ts'], preset: 'ts-jest', - roots: ['tests', 'src'], testEnvironment: 'node', - testMatch: ['/**/*.test.ts', '/src/**/*.test.ts'], - setupFilesAfterEnv: ['/jest.setupTests.ts'], testTimeout: 30000, - verbose: true, transform: { '^.+\\.(ts|tsx)$': [ 'ts-jest', @@ -17,3 +13,25 @@ export default { ] } }; + +export default { + projects: [ + { + displayName: 'unit', + roots: ['src'], + testMatch: ['/src/**/*.test.ts'], + setupFilesAfterEnv: ['/jest.setupTests.ts'], + ...baseConfig + }, + { + displayName: 'e2e', + roots: ['tests'], + testMatch: ['/tests/**/*.test.ts'], + setupFilesAfterEnv: ['/tests/jest.setupTests.ts'], + transformIgnorePatterns: [ + '/dist/' + ], + ...baseConfig + } + ] +}; diff --git a/jest.setupTests.ts b/jest.setupTests.ts index a5cfdb1..d07915e 100644 --- a/jest.setupTests.ts +++ b/jest.setupTests.ts @@ -1,14 +1,15 @@ -// Shared helpers for all Jest tests +// Shared helpers for Jest unit tests /** * Note: Mock @patternfly/patternfly-component-schemas/json to avoid top-level await issues in Jest - * - This package uses top-level await which Jest cannot handle without transformation. - * - Individual tests can override this mock if needed + * - Individual tests can override mock */ jest.mock('@patternfly/patternfly-component-schemas/json', () => ({ componentNames: ['Button', 'Alert', 'Card', 'Modal', 'AlertGroup', 'Text', 'TextInput'], - getComponentSchema: jest.fn().mockImplementation((name: string) => { - if (name === 'Button') { + getComponentSchema: jest.fn().mockImplementation((name: unknown) => { + const componentName = name as string; + + if (componentName === 'Button') { return Promise.resolve({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', @@ -24,6 +25,6 @@ jest.mock('@patternfly/patternfly-component-schemas/json', () => ({ }); } - throw new Error(`Component "${name}" not found`); + throw new Error(`Component "${componentName}" not found`); }) }), { virtual: true }); diff --git a/tests/__fixtures__/content/hosted.input.json b/tests/__fixtures__/content/hosted.input.json deleted file mode 100644 index d6c71de..0000000 --- a/tests/__fixtures__/content/hosted.input.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "urlList": [ - "react-core/6.0.0/llms.txt" - ] -} diff --git a/tests/__fixtures__/content/local-two-files.input.json b/tests/__fixtures__/content/local-two-files.input.json deleted file mode 100644 index 76a967e..0000000 --- a/tests/__fixtures__/content/local-two-files.input.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "urlList": [ - "documentation/guidelines/README.md", - "documentation/components/README.md" - ] -} diff --git a/tests/__snapshots__/mcp.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap similarity index 99% rename from tests/__snapshots__/mcp.test.ts.snap rename to tests/__snapshots__/stdioTransport.test.ts.snap index 01fff6b..7c34c9f 100644 --- a/tests/__snapshots__/mcp.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -274,7 +274,7 @@ exports[`Hosted mode, --docs-host should read llms-files and includes expected t ] `; -exports[`PatternFly MCP should concatenate headers and separator with two local files 1`] = ` +exports[`PatternFly MCP, STDIO should concatenate headers and separator with two local files 1`] = ` "# Documentation from documentation/guidelines/README.md # PatternFly Guidelines @@ -382,7 +382,7 @@ You can find documentation on PatternFly's components at [PatternFly All compone " `; -exports[`PatternFly MCP should expose expected tools and stable shape 1`] = ` +exports[`PatternFly MCP, STDIO should expose expected tools and stable shape 1`] = ` { "toolNames": [ "componentSchemas", diff --git a/tests/jest.setupTests.ts b/tests/jest.setupTests.ts new file mode 100644 index 0000000..629bfbc --- /dev/null +++ b/tests/jest.setupTests.ts @@ -0,0 +1,21 @@ +// Shared helpers for e2e Jest tests +import { jest } from '@jest/globals'; + +/** + * Store the original fetch implementation + * Tests can access this to get the real fetch when needed + */ +export const originalFetch = global.fetch; + +/** + * Set up global.fetch spy for e2e tests + * + * This creates a spy on global.fetch that can be overridden by individual tests. + * Tests can use jest.spyOn(global, 'fetch').mockImplementation() to customize behavior. + * + * The spy is automatically restored after each test suite via jest.restoreAllMocks(). + * Individual tests should restore their mocks in afterAll/afterEach if needed. + */ +beforeAll(() => { + jest.spyOn(global, 'fetch'); +}); diff --git a/tests/mcp.test.ts b/tests/stdioTransport.test.ts similarity index 69% rename from tests/mcp.test.ts rename to tests/stdioTransport.test.ts index a02d80b..9bb6e7a 100644 --- a/tests/mcp.test.ts +++ b/tests/stdioTransport.test.ts @@ -1,12 +1,12 @@ /** * Requires: npm run build prior to running Jest. */ +import { startServer, type StdioTransportClient } from './utils/stdioTransportClient'; +import { loadFixture } from './utils/fixtures'; +import { setupFetchMock } from './utils/fetchMock'; -import { startServer, type StdioClient } from './utils/stdioClient'; -import { loadFixture, startHttpFixture } from './utils/httpFixtureServer'; - -describe('PatternFly MCP', () => { - let client: StdioClient; +describe('PatternFly MCP, STDIO', () => { + let client: StdioTransportClient; beforeEach(async () => { client = await startServer(); @@ -28,17 +28,17 @@ describe('PatternFly MCP', () => { } }; - const resp = await client.send(req); - const text = resp?.result?.content?.[0]?.text || ''; + const response = await client.send(req); + const text = response?.result?.content?.[0]?.text || ''; expect(text.startsWith('# Documentation from')).toBe(true); expect(text).toMatchSnapshot(); }); it('should expose expected tools and stable shape', async () => { - const resp = await client.send({ method: 'tools/list' }); - const tools = resp?.result?.tools || []; - const toolNames = tools.map(tool => tool.name).sort(); + const response = await client.send({ method: 'tools/list' }); + const tools = response?.result?.tools || []; + const toolNames = tools.map((tool: any) => tool.name).sort(); expect(toolNames).toEqual(expect.arrayContaining(['usePatternFlyDocs', 'fetchDocs'])); expect({ toolNames }).toMatchSnapshot(); @@ -46,7 +46,7 @@ describe('PatternFly MCP', () => { }); describe('Hosted mode, --docs-host', () => { - let client: StdioClient; + let client: StdioTransportClient; beforeEach(async () => { client = await startServer({ args: ['--docs-host'] }); @@ -72,9 +72,9 @@ describe('Hosted mode, --docs-host', () => { }); describe('External URLs', () => { - let fixture: { baseUrl: string; close: () => Promise; }; + let fetchMock: Awaited> | undefined; let url: string; - let client: StdioClient; + let client: StdioTransportClient; beforeEach(async () => { client = await startServer(); @@ -83,23 +83,21 @@ describe('External URLs', () => { afterEach(async () => client.stop()); beforeAll(async () => { - const body = loadFixture('README.md'); - - fixture = await startHttpFixture({ - routes: { - '/readme': { + // Note: The helper creates index-based paths based on routing (/0, /1, etc.), so we use /0 for the first route + fetchMock = await setupFetchMock({ + routes: [ + { + url: /\/readme$/, status: 200, headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - body + body: loadFixture('README.md') } - } + ] }); - url = `${fixture.baseUrl}/readme`; + url = `${fetchMock.fixture.baseUrl}/0`; }); - afterAll(async () => { - await fixture.close(); - }); + afterAll(async () => fetchMock?.cleanup()); it('should fetch a document', async () => { const req = { diff --git a/tests/utils/fetchMock.ts b/tests/utils/fetchMock.ts new file mode 100644 index 0000000..3367995 --- /dev/null +++ b/tests/utils/fetchMock.ts @@ -0,0 +1,283 @@ +/** + * Fetch Mocking Utilities for E2E Tests + * + * Provides high-level helpers for mocking fetch requests by routing them to a local fixture server. + */ +import http from 'node:http'; +import { jest } from '@jest/globals'; +import { originalFetch } from '../jest.setupTests'; + +type HeadersMap = Record; +type RouteHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void; + +interface Route { + status?: number; + headers?: HeadersMap; + body?: string | Buffer | Uint8Array | RouteHandler; +} + +type RoutesMap = Record; + +interface StartHttpFixtureOptions { + routes?: RoutesMap; + address?: string; +} + +/** + * Start an HTTP server with a set of routes and return a URL to access them. + * + * Internal utility used by setupFetchMock to create a local fixture server. + * + * @param options - HTTP fixture options + * @param options.routes - Map of URL paths to route handlers + * @param options.address - Server address to listen on (default: '127.0.0.1') + * @returns Promise that resolves with server baseUrl and close method + */ +const startHttpFixture = ( + { routes = {}, address = '127.0.0.1' }: StartHttpFixtureOptions = {} +): Promise<{ baseUrl: string; close: () => Promise }> => + new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = req.url || ''; + const route = routes[url]; + + if (!route) { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + + return res.end('Not Found'); + } + + const { status = 200, headers = {}, body } = route; + + res.statusCode = status; + + Object.entries(headers).forEach(([key, value]) => { + res.setHeader(key, value); + }); + + if (typeof body === 'function') { + return (body as RouteHandler)(req, res); + } + + return res.end(body as string | Buffer | Uint8Array | undefined); + }); + + server.listen(0, address, () => { + const addr = server.address(); + + if (addr && typeof addr !== 'string') { + const host = addr.address === '::' ? address : addr.address; + const baseUrl = `http://${host}:${addr.port}`; + + resolve({ baseUrl, close: () => new Promise(res => server.close(() => res())) }); + } else { + // Fallback if the address isn't available as AddressInfo + resolve({ baseUrl: `http://${address}`, close: () => new Promise(res => server.close(() => res())) }); + } + }); + + server.on('error', reject); + }); + +type StartHttpFixtureResult = Awaited>; + +/** + * Route configuration for fetch mocking + */ +export interface FetchRoute { + + /** URL pattern to match (supports wildcards with *) */ + url: string | RegExp; + + /** HTTP status code */ + status?: number; + + /** Response headers */ + headers?: Record; + + /** Response body (string, Buffer, or function) */ + body?: string | Buffer | ((req: Request) => Promise | string | Buffer); +} + +/** + * Fetch mock helper that routes remote HTTP requests to a fixture server + * + * This helper masks the complexity of setting up fetch mocks and fixture servers. + * It automatically intercepts remote HTTP requests and routes them to a local fixture server. + * + * @example + * ```typescript + * const mockFetch = setupFetchMock({ + * routes: [ + * { url: 'https://example.com/doc.md', body: '# Test Doc' }, + * { url: /https:\/\/github\.com\/.*\.md/, body: '# GitHub Doc' } + * ], + * excludePorts: [5001] // Don't intercept MCP server requests + * }); + * + * // Later in afterAll: + * await mockFetch.cleanup(); + * ``` + */ +export interface FetchMockSetup { + + /** Routes to mock */ + routes?: FetchRoute[]; + + /** Ports to exclude from interception (e.g., MCP server port) */ + excludePorts?: number[]; + + /** Fixture server address (default: '127.0.0.1') */ + address?: string; +} + +export interface FetchMockResult { + + /** Cleanup function to restore fetch and close fixture server */ + cleanup: () => Promise; + + /** Fixture server instance */ + fixture: StartHttpFixtureResult; +} + +/** + * Set up fetch mocking with route-based configuration + * + * Useful when + * - You need different responses for different URLs + * - You need custom status codes or headers per route + * - You need more control over routing + * + * @param options - Fetch mock configuration + * @returns Cleanup function and fixture server instance + */ +export const setupFetchMock = async (options: FetchMockSetup = {}): Promise => { + const { + routes = [], + excludePorts = [], + address = '127.0.0.1' + } = options; + + // Convert routes to fixture server format + const fixtureRoutes: Record; body?: string | Buffer }> = {}; + + routes.forEach((route, index) => { + // Use index-based path for fixture server, we'll match by URL pattern in the mock + const path = `/${index}`; + + fixtureRoutes[path] = { + status: route.status || 200, + headers: route.headers || { 'Content-Type': 'text/plain; charset=utf-8' }, + body: typeof route.body === 'string' || route.body instanceof Buffer + ? route.body + : '# Mocked Response' + }; + }); + + // Start fixture server + const fixture = await startHttpFixture({ routes: fixtureRoutes, address }); + + // Create URL pattern matcher + const matchRoute = (url: string): FetchRoute | undefined => routes.find(route => { + if (route.url instanceof RegExp) { + return route.url.test(url); + } + // Support wildcards + const pattern = route.url.replace(/\*/g, '.*'); + const regex = new RegExp(`^${pattern}$`); + + return regex.test(url); + }); + + // Set up fetch mock + const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + + // Check if URL should be excluded (e.g., MCP server requests) + const shouldExclude = excludePorts.some(port => url.includes(`:${port}`)); + + // Only intercept remote HTTP/HTTPS URLs that match our routes + if (!shouldExclude && (url.startsWith('http://') || url.startsWith('https://'))) { + const matchedRoute = matchRoute(url); + + if (matchedRoute) { + // Find the route index to get the fixture path + const routeIndex = routes.indexOf(matchedRoute); + const fixturePath = `/${routeIndex}`; + const fixtureUrl = `${fixture.baseUrl}${fixturePath}`; + + // Handle function body + if (typeof matchedRoute.body === 'function') { + const bodyResult = await matchedRoute.body(new Request(url, init)); + // Create a Response with the function result + const responseBody = typeof bodyResult === 'string' + ? bodyResult + : bodyResult instanceof Buffer + ? bodyResult + : String(bodyResult); + + return new Response(responseBody as BodyInit, { + status: matchedRoute.status || 200, + headers: matchedRoute.headers || {} + }); + } + + // Use original fetch to hit the fixture server + return originalFetch(fixtureUrl, init); + } + } + + // For non-matching URLs or excluded ports, use original fetch + return originalFetch(input as RequestInfo, init); + }); + + return { + fixture, + cleanup: async () => { + fetchSpy.mockRestore(); + await fixture.close(); + } + }; +}; + +/** + * Simple fetch mock that routes all remote URLs to a single fixture + * + * Useful when + * - You need the same response for all external requests + * - You want a quick mock without route configuration + * - Testing scenarios where the response content doesn't matter + * + * @param body - Response body for all intercepted requests + * @param options - Additional options + * @param options.excludePorts - Ports to exclude from interception (e.g., MCP server port) + * @param options.address - Fixture server address (default: '127.0.0.1') + * @returns Cleanup function and fixture server instance + */ +export const setupSimpleFetchMock = async ( + body: string, + options: { excludePorts?: number[]; address?: string } = {} +): Promise => { + const mockOptions: FetchMockSetup = { + routes: [ + { + url: /^https?:\/\/.*/, + body, + status: 200, + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + } + ] + }; + + if (options.excludePorts) { + mockOptions.excludePorts = options.excludePorts; + } + + if (options.address) { + mockOptions.address = options.address; + } + + return setupFetchMock(mockOptions); +}; + diff --git a/tests/utils/fixtures.ts b/tests/utils/fixtures.ts new file mode 100644 index 0000000..ae75234 --- /dev/null +++ b/tests/utils/fixtures.ts @@ -0,0 +1,18 @@ +/** + * Fixture Loading Utilities for E2E Tests + * + * Provides helpers for loading fixture files from the test fixtures directory. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Load a fixture file from the __fixtures__ directory. + * + * @param relPath - Relative path to the fixture file. + * @returns File content. + * @throws {Error} File cannot be found or read. + */ +export const loadFixture = (relPath: string): string => + fs.readFileSync(path.join(process.cwd(), 'tests', '__fixtures__', 'http', relPath), 'utf-8'); + diff --git a/tests/utils/httpFixtureServer.ts b/tests/utils/httpFixtureServer.ts deleted file mode 100644 index 2b39c2d..0000000 --- a/tests/utils/httpFixtureServer.ts +++ /dev/null @@ -1,91 +0,0 @@ -import http from 'node:http'; -import fs from 'node:fs'; -import path from 'node:path'; - -type HeadersMap = Record; - -type RouteHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void; - -interface Route { - status?: number; - headers?: HeadersMap; - body?: string | Buffer | Uint8Array | RouteHandler; -} - -type RoutesMap = Record; - -interface StartHttpFixtureOptions { - routes?: RoutesMap; - address?: string; -} - -interface StartHttpFixtureResult { - baseUrl: string; - close: () => Promise; -} - -/** - * Start an HTTP server with a set of routes and return a URL to access them. - * - * @param {StartHttpFixtureOptions} [options] - HTTP fixture options. - * @param {Object.} [options.routes={}] - Routes to be served by the HTTP server. Keys are URL paths, and values define the route's behavior (status, headers, and body). - * @param {string} [options.address='127.0.0.1'] - Address the server should bind to. Defaults to '127.0.0.1'. - * @returns {Promise} Promise that resolves with an object containing the `baseUrl` of the server and a `close` method to stop the server. - */ -export const startHttpFixture = ( - { routes = {}, address = '127.0.0.1' }: StartHttpFixtureOptions = {} -): Promise => - new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - const url = req.url || ''; - const route = routes[url]; - - if (!route) { - res.statusCode = 404; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - - return res.end('Not Found'); - } - - const { status = 200, headers = {}, body } = route; - - res.statusCode = status; - - Object.entries(headers).forEach(([key, value]) => { - res.setHeader(key, value); - }); - - if (typeof body === 'function') { - return (body as RouteHandler)(req, res); - } - - return res.end(body as string | Buffer | Uint8Array | undefined); - }); - - server.listen(0, address, () => { - const addr = server.address(); - - if (addr && typeof addr !== 'string') { - const host = addr.address === '::' ? address : addr.address; - const baseUrl = `http://${host}:${addr.port}`; - - resolve({ baseUrl, close: () => new Promise(res => server.close(() => res())) }); - } else { - // Fallback if the address isn't available as AddressInfo - resolve({ baseUrl: `http://${address}`, close: () => new Promise(res => server.close(() => res())) }); - } - }); - - server.on('error', reject); - }); - -/** - * Load a fixture file from the __fixtures__ directory. - * - * @param {string} relPath - Relative path to the fixture file. - * @returns {string} File content. - * @throws {Error} File cannot be found or read. - */ -export const loadFixture = (relPath: string): string => - fs.readFileSync(path.join(process.cwd(), 'tests', '__fixtures__', 'http', relPath), 'utf-8'); - diff --git a/tests/utils/stdioClient.ts b/tests/utils/stdioClient.ts deleted file mode 100644 index ecda685..0000000 --- a/tests/utils/stdioClient.ts +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env node - -// Lightweight JSON-RPC over stdio client for the built MCP server (dist/index.js) -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; - -// JSON-like value used in requests/responses -export type Json = null | boolean | number | string | Json[] | { [k: string]: Json }; - -export interface RpcError { - code: number; - message: string; - data?: Json; -} - -export interface RpcResultCommon { - content?: Array<({ text?: string } & Record)>; - tools?: Array<({ name: string } & Record)>; - [k: string]: unknown; -} - -export interface RpcResponse { - jsonrpc?: '2.0'; - id: number | string; - result?: RpcResultCommon; - error?: RpcError; -} - -export interface RpcRequest { - jsonrpc?: '2.0'; - id?: number | string; - method: string; - params?: Json; -} - -export interface StartOptions { - command?: string; - serverPath?: string; - args?: string[]; - env?: Record; -} - -interface PendingEntry { - resolve: (value: RpcResponse) => void; - reject: (reason?: Error) => void; - timer: NodeJS.Timeout; -} - -export interface StdioClient { - proc: ChildProcessWithoutNullStreams; - send: (request: RpcRequest, opts?: { timeoutMs?: number }) => Promise; - stop: (signal?: NodeJS.Signals) => Promise; -} - -/** - * Check if the value is a valid RPC response. - * - * @param {RpcResponse|unknown} val - * @returns {boolean} Is value an RpcResponse. - */ -export const isRpcResponse = (val: RpcResponse | unknown): boolean => - typeof val === 'object' && val !== null && ('jsonrpc' in val) && ('id' in val); - -/** - * Start the MCP server process and return a client with send/stop APIs. - * - * Options: - * - command: node command to run (default: 'node') - * - serverPath: path to built server (default: process.env.SERVER_PATH || 'dist/index.js') - * - args: additional args to pass to server (e.g., ['--docs-host']) - * - env: env vars to pass to child - * - * @param params - * @param params.command - * @param params.serverPath - * @param params.args - * @param params.env - */ -export const startServer = async ({ - command = 'node', - serverPath = process.env.SERVER_PATH || 'dist/index.js', - args = [], - env = {} -}: StartOptions = {}): Promise => { - const proc: ChildProcessWithoutNullStreams = spawn(command, [serverPath, ...args], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, ...env } - }); - - const pending = new Map(); // id -> { resolve, reject, timer } - let buffer = ''; - let stderr = ''; - let isClosed = false; - - const clearAllPending = (reason: string) => { - for (const [id, p] of pending.entries()) { - clearTimeout(p.timer); - p.reject(new Error(`Server closed before response id=${String(id)}. ${reason || ''} stderr: ${stderr}`)); - pending.delete(id); - } - }; - - proc.stdout.on('data', (data: Buffer) => { - buffer += data.toString(); - let idx: number; - - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).trim(); - - buffer = buffer.slice(idx + 1); - - if (!line) { - continue; - } - - let parsed: RpcResponse = {} as RpcResponse; - - try { - parsed = JSON.parse(line); - } catch {} - - if (isRpcResponse(parsed) === false) { - continue; - } - - if (pending.has(parsed.id)) { - const entry = pending.get(parsed.id)!; - - clearTimeout(entry.timer); - pending.delete(parsed.id); - entry.resolve(parsed); - } - } - }); - - proc.stderr.on('data', (data: Buffer) => { - stderr += data.toString(); - }); - - const stop = (signal: NodeJS.Signals = 'SIGINT'): Promise => new Promise(resolve => { - if (isClosed) { - return resolve(); - } - - isClosed = true; - - try { - proc.kill(signal); - } catch { - // ignore - } - - proc.on('close', () => { - clearAllPending('Process closed.'); - resolve(); - }); - }); - - const send: StdioClient['send'] = (request, { timeoutMs = 20000 } = {}) => new Promise((resolve, reject) => { - if (!request || typeof request !== 'object') { - return reject(new Error('Invalid request')); - } - - const id: number | string = request.id || Math.floor(Math.random() * 1e9); - const rpc: RpcRequest = { jsonrpc: '2.0', ...request, id }; - const ms = Number(process.env.TEST_TIMEOUT_MS || timeoutMs); - const timer = setTimeout(() => { - pending.delete(id); - reject(new Error(`Timeout waiting for response id=${String(id)}. stderr: ${stderr}`)); - }, ms); - - pending.set(id, { resolve, reject, timer }); - - try { - proc.stdin.write(JSON.stringify(rpc) + '\n'); - } catch (err) { - clearTimeout(timer); - pending.delete(id); - - const error = err instanceof Error ? err : new Error(String(err)); - - reject(error); - } - }); - - return { proc, send, stop }; -}; diff --git a/tests/utils/stdioTransportClient.ts b/tests/utils/stdioTransportClient.ts new file mode 100644 index 0000000..8e231d5 --- /dev/null +++ b/tests/utils/stdioTransportClient.ts @@ -0,0 +1,189 @@ +/** + * STDIO Transport Client for E2E Testing + * Uses the MCP SDK's built-in Client and StdioClientTransport + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +export interface StartOptions { + command?: string; + serverPath?: string; + args?: string[]; + env?: Record; +} + +export interface RpcResponse { + jsonrpc?: '2.0'; + id: number | string | null; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +} + +export interface StdioTransportClient { + send: (request: { method: string; params?: any }, opts?: { timeoutMs?: number }) => Promise; + stop: (signal?: NodeJS.Signals) => Promise; + close: () => Promise; // Alias for stop() +} + +/** + * Start the MCP server process and return a client with send/stop APIs. + * + * Uses the MCP SDK's StdioClientTransport and Client for high-level MCP protocol handling. + * + * @param options - Server configuration options + * @param options.command - Node command to run (default: 'node') + * @param options.serverPath - Path to built server (default: process.env.SERVER_PATH || 'dist/index.js') + * @param options.args - Additional args to pass to server (e.g., ['--docs-host']) + * @param options.env - Environment variables for the child process + */ +export const startServer = async ({ + command = 'node', + serverPath = process.env.SERVER_PATH || 'dist/index.js', + args = [], + env = {} +}: StartOptions = {}): Promise => { + // Create stdio transport - this will spawn the server process + // Set stderr to 'pipe' so we can handle server logs separately from JSON-RPC messages + const transport = new StdioClientTransport({ + command, + args: [serverPath, ...args], + env: { ...process.env, ...env } as any, + stderr: 'pipe' // Pipe stderr so server logs don't interfere with JSON-RPC on stdout + }); + + // Create MCP SDK client + const mcpClient = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + // Track whether we're intentionally closing the client + // This allows us to suppress expected errors during cleanup + let isClosing = false; + + // Set up error handler - only log unexpected errors + // Note: JSON parse errors from server console.log/info messages are expected + // The server logs to stdout, which the SDK tries to parse as JSON-RPC messages + mcpClient.onerror = error => { + // Only log errors that occur when not intentionally closing + // Ignore JSON parse errors - happens when the server logs to stdout (expected behavior) + // The SDK will skip non-JSON lines and continue processing + if (!isClosing) { + const isJsonParseError = error instanceof SyntaxError && + (error.message.includes('is not valid JSON') || error.message.includes('Unexpected token')); + + if (!isJsonParseError) { + console.error('MCP Client error:', error); + } + } + }; + + // Connect client to transport (this automatically starts transport and initializes the session) + await mcpClient.connect(transport); + + // Access stderr stream if available to handle server logs + // This prevents server logs from interfering with JSON-RPC parsing + if (transport.stderr) { + transport.stderr.on('data', (_data: Buffer) => { + // Server logs go to stderr, we can optionally log them for debugging, + // but we don't need to do anything with them for the tests to work + }); + } + + // Wait for the server to be ready + await new Promise(resolve => { + const timer = setTimeout(resolve, 50); + + timer.unref(); + }); + + const stop = async (_signal: NodeJS.Signals = 'SIGINT'): Promise => { + if (isClosing) { + return; + } + + isClosing = true; + + // Remove the error handler to prevent any error logging during cleanup + mcpClient.onerror = null as any; + + // Close client first + await mcpClient.close(); + + // Close transport (this will kill the child process) + await transport.close(); + + // Small delay to ensure cleanup completes + await new Promise(resolve => { + const timer = setTimeout(resolve, 50); + + timer.unref(); + }); + }; + + return { + async send(request: { method: string; params?: any }, _opts?: { timeoutMs?: number }): Promise { + try { + // Use high-level SDK methods when available for better type safety + if (request.method === 'tools/list') { + const result = await mcpClient.listTools(request.params); + + return { + jsonrpc: '2.0', + id: null, + result: result as any + }; + } + + if (request.method === 'tools/call' && request.params?.name) { + const result = await mcpClient.callTool({ + name: request.params.name, + arguments: request.params.arguments || {} + }); + + return { + jsonrpc: '2.0', + id: null, + result: result as any + }; + } + + // For other requests, use the client's request method + // Note: The SDK's request method expects a properly formatted request + const result = await mcpClient.request({ + method: request.method, + params: request.params + } as any, ResultSchema); + + return { + jsonrpc: '2.0', + id: null, + result: result as any + }; + } catch (error) { + // If request fails, return error response + return { + jsonrpc: '2.0', + id: null, + error: { + code: -1, + message: error instanceof Error ? error.message : String(error) + } + }; + } + }, + + stop, + close: stop + }; +};