Skip to main content
Glama
httpClient.ts6.24 kB
/** * HTTP client wrapper for testing FastAPI endpoints * * Spawns the Hostaway FastAPI server and provides methods to: * - Call API endpoints via HTTP * - Estimate token usage * - Manage server lifecycle */ import { spawn, ChildProcess } from 'child_process'; import { estimateTokens } from './tokenEstimator.js'; export interface HTTPClientOptions { /** Environment variables to pass to the server */ env?: Record<string, string>; /** Server startup timeout in ms */ timeout?: number; /** Port to run server on (default: random) */ port?: number; } export interface ToolCallResult<T = unknown> { /** API response content */ content: T; /** Estimated token count for the response */ estimatedTokens: number; /** Whether the response indicates an error */ isError: boolean; /** Error message if isError is true */ errorMessage?: string; } /** * HTTP client for testing FastAPI endpoints * * Manages the lifecycle of a FastAPI server process and provides * type-safe methods for calling endpoints and estimating token usage. */ export class HTTPTestClient { private process?: ChildProcess; private baseUrl?: string; private options: HTTPClientOptions; constructor(options: HTTPClientOptions = {}) { this.options = { timeout: 10000, port: 0, // Random port ...options, }; } /** * Start the FastAPI server process */ async start(): Promise<void> { if (this.process) { throw new Error('Server already started'); } // Load .env file from project root const projectRoot = process.cwd().replace('/test', ''); const envPath = `${projectRoot}/.env`; const envFile = await import('fs').then(fs => fs.promises.readFile(envPath, 'utf-8')); const envVars: Record<string, string> = {}; for (const line of envFile.split('\n')) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); if (key && valueParts.length > 0) { envVars[key.trim()] = valueParts.join('=').trim(); } } } // Merge test environment with custom env vars and .env file const filteredEnv: Record<string, string> = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { filteredEnv[key] = value; } } const env: Record<string, string> = { ...filteredEnv, ...envVars, // Add .env vars ...(this.options.env || {}), // Custom test env vars override }; // Spawn the FastAPI server process const venvPython = projectRoot + '/.venv/bin/python'; this.process = spawn( venvPython, ['-m', 'uvicorn', 'src.api.main:app', '--host', '127.0.0.1', '--port', String(this.options.port)], { env, cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], } ); // Capture server output to extract port let serverOutput = ''; this.process.stdout?.on('data', (data) => { serverOutput += data.toString(); }); this.process.stderr?.on('data', (data) => { serverOutput += data.toString(); }); // Handle process errors this.process.on('error', (error) => { throw new Error(`Failed to start server: ${error.message}`); }); // Wait for server to be ready const startTime = Date.now(); while (Date.now() - startTime < this.options.timeout!) { // Look for "Uvicorn running on" in output const match = serverOutput.match(/Uvicorn running on http:\/\/127\.0\.0\.1:(\d+)/); if (match) { const port = match[1]; this.baseUrl = `http://127.0.0.1:${port}`; // Verify server is responding try { const response = await fetch(`${this.baseUrl}/`); if (response.ok) { return; // Server is ready } } catch { // Server not ready yet } } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`Server failed to start within ${this.options.timeout}ms. Output: ${serverOutput}`); } /** * Call an API endpoint and return the result with token estimation */ async callEndpoint<T = unknown>(method: string, path: string, params?: Record<string, unknown>): Promise<ToolCallResult<T>> { if (!this.baseUrl) { throw new Error('Server not started. Call start() first.'); } try { // Build URL with query parameters const url = new URL(`${this.baseUrl}${path}`); if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) { url.searchParams.append(key, String(value)); } } } const response = await fetch(url.toString(), { method, headers: { 'Content-Type': 'application/json', 'X-API-Key': 'mcp_test_GZeJlFu9ynoCLIFPHPB3vwyHNdjRz3o53d8bvZz7sfw', }, }); const contentText = await response.text(); const estimatedTokens = estimateTokens(contentText); const isError = !response.ok; return { content: JSON.parse(contentText) as T, estimatedTokens, isError, errorMessage: isError ? contentText : undefined, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: {} as T, estimatedTokens: estimateTokens(errorMessage), isError: true, errorMessage, }; } } /** * Stop the server process and cleanup */ async stop(): Promise<void> { if (this.process) { this.process.kill('SIGTERM'); // Wait for graceful shutdown (max 5 seconds) await new Promise<void>((resolve) => { const timeout = setTimeout(() => { // Force kill if not shutdown gracefully if (this.process) { this.process.kill('SIGKILL'); } resolve(); }, 5000); this.process!.on('exit', () => { clearTimeout(timeout); resolve(); }); }); } this.process = undefined; this.baseUrl = undefined; } }

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/darrentmorgan/hostaway-mcp'

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