Skip to main content
Glama

Bruno MCP Server

by jcr82
bruno-cli.ts37.8 kB
import { execa } from 'execa'; import * as fs from 'fs/promises'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { randomUUID } from 'crypto'; import { tmpdir } from 'os'; import { createRequire } from 'module'; import { getConfigLoader } from './config.js'; import { getPerformanceManager } from './performance.js'; export interface BrunoRunOptions { environment?: string; envVariables?: Record<string, string>; folderPath?: string; format?: 'json' | 'junit' | 'html'; output?: string; recursive?: boolean; testsOnly?: boolean; bail?: boolean; // Report generation options reporterJson?: string; // Path to write JSON report reporterJunit?: string; // Path to write JUnit XML report reporterHtml?: string; // Path to write HTML report } export interface BrunoRunResult { stdout: string; stderr: string; exitCode: number; summary?: { totalRequests: number; passedRequests: number; failedRequests: number; totalDuration: number; }; results?: Array<{ name: string; passed: boolean; status: number; duration: number; error?: string; request?: { method?: string; url?: string; headers?: Record<string, string>; body?: any; }; response?: { status?: number; statusText?: string; headers?: Record<string, string>; body?: any; responseTime?: number; }; assertions?: Array<{ name: string; passed: boolean; error?: string; }>; }>; } export interface BrunoRequest { name: string; method?: string; url?: string; folder?: string; path?: string; } export class BrunoCLI { private brunoCommand: string = 'bru'; constructor(brunoPath?: string) { if (brunoPath) { this.brunoCommand = brunoPath; } else { // Check configuration for custom Bruno CLI path const configLoader = getConfigLoader(); const config = configLoader.getConfig(); if (config.brunoCliPath) { this.brunoCommand = config.brunoCliPath; } else { // Try to find the local Bruno CLI installation this.brunoCommand = this.findBrunoCLI(); } } } /** * Find the Bruno CLI executable */ private findBrunoCLI(): string { const require = createRequire(import.meta.url); const fsSync = require('fs'); // Get the directory where this module is located const moduleDir = path.dirname(fileURLToPath(import.meta.url)); // First, try to find it relative to the current module location // This works whether we're in src/ or dist/ const localBruPath = path.join(moduleDir, '..', 'node_modules', '.bin', 'bru'); // On Windows, the executable might have a .cmd extension const isWindows = process.platform === 'win32'; const executablePath = isWindows ? `${localBruPath}.cmd` : localBruPath; // Check if local installation exists try { if (fsSync.existsSync(executablePath)) { return executablePath; } } catch { // Fall through to try process.cwd() } // Try from process.cwd() as a fallback (for backward compatibility) const cwdBruPath = path.join(process.cwd(), 'node_modules', '.bin', 'bru'); const cwdExecutablePath = isWindows ? `${cwdBruPath}.cmd` : cwdBruPath; try { if (fsSync.existsSync(cwdExecutablePath)) { return cwdExecutablePath; } } catch { // Fall through to use global command } // Fall back to global command (in case it's installed globally) return 'bru'; } /** * Check if Bruno CLI is available */ async isAvailable(): Promise<boolean> { try { const result = await execa(this.brunoCommand, ['--version']); console.error(`Bruno CLI found at: ${this.brunoCommand}`); console.error(`Bruno CLI version: ${result.stdout}`); return true; } catch (error) { console.error(`Bruno CLI not found at: ${this.brunoCommand}`); return false; } } /** * Run a specific request from a collection * Bruno CLI must be run from within the collection directory */ async runRequest( collectionPath: string, requestName: string, options: BrunoRunOptions = {} ): Promise<BrunoRunResult> { // Find the .bru file for this request const requestFile = await this.findRequestFile(collectionPath, requestName); if (!requestFile) { throw new Error(`Request "${requestName}" not found in collection`); } // Get relative path from collection root const relativePath = path.relative(collectionPath, requestFile); // Create temporary file for JSON output const tempFile = path.join(tmpdir(), `bruno-result-${randomUUID()}.json`); // Build command arguments - run specific file const args = ['run', relativePath]; // Always use JSON format to capture response data args.push('--format', 'json'); args.push('--output', tempFile); // Add optional parameters if (options.environment) { let envPath = options.environment; // Check if it's an absolute path if (path.isAbsolute(envPath)) { // If absolute path, get just the filename without extension // Bruno expects just the environment name, not the full path const basename = path.basename(envPath, '.bru'); envPath = basename; } else if (envPath.includes('/') || envPath.includes('\\')) { // If it's a relative path with separators, get just the filename const basename = path.basename(envPath, '.bru'); envPath = basename; } else if (envPath.endsWith('.bru')) { // If it ends with .bru, remove the extension envPath = envPath.replace(/\.bru$/, ''); } // If just a name (no path separators, no extension), use as-is args.push('--env', envPath); } // Add environment variable overrides if (options.envVariables) { for (const [key, value] of Object.entries(options.envVariables)) { args.push('--env-var', `${key}=${value}`); } } if (options.testsOnly) { args.push('--tests-only'); } if (options.bail) { args.push('--bail'); } // Add reporter options for report generation if (options.reporterJson) { args.push('--reporter-json', options.reporterJson); } if (options.reporterJunit) { args.push('--reporter-junit', options.reporterJunit); } if (options.reporterHtml) { args.push('--reporter-html', options.reporterHtml); } try { // Get timeout configuration const configLoader = getConfigLoader(); const timeout = configLoader.getTimeout(); // Run Bruno CLI from within the collection directory const result = await execa(this.brunoCommand, args, { cwd: collectionPath, env: { ...process.env }, reject: false, timeout: timeout.request }); // If Bruno CLI failed, handle the error if (result.exitCode !== 0 || result.failed) { throw result; } // Read the JSON output file // Note: If custom reporters are used, Bruno CLI may not write to the temp file let jsonResult: any = null; const hasCustomReporters = options.reporterJson || options.reporterJunit || options.reporterHtml; if (!hasCustomReporters) { // Only try to read temp file if no custom reporters are specified try { const jsonContent = await fs.readFile(tempFile, 'utf-8'); jsonResult = JSON.parse(jsonContent); } catch (error) { console.error('Failed to read Bruno output file:', error); } } // Clean up temp file try { await fs.unlink(tempFile); } catch { // Ignore cleanup errors } return this.parseRunResult(result, jsonResult); } catch (error) { // Clean up temp file on error try { await fs.unlink(tempFile); } catch { // Ignore cleanup errors } throw this.handleError(error); } } /** * Run an entire collection or folder * Bruno CLI must be run from within the collection directory */ async runCollection( collectionPath: string, options: BrunoRunOptions = {} ): Promise<BrunoRunResult> { // Determine what to run (folder or entire collection) let targetPath = '.'; if (options.folderPath) { // Run specific folder targetPath = options.folderPath; } // Create temporary file for JSON output const tempFile = path.join(tmpdir(), `bruno-result-${randomUUID()}.json`); // Build command arguments const args = ['run', targetPath]; // Add recursive flag (usually needed for folders) if (options.recursive !== false) { args.push('-r'); } // Always use JSON format to capture response data args.push('--format', 'json'); args.push('--output', tempFile); // Add optional parameters if (options.environment) { let envPath = options.environment; // Check if it's an absolute path if (path.isAbsolute(envPath)) { // If absolute path, get just the filename without extension // Bruno expects just the environment name, not the full path const basename = path.basename(envPath, '.bru'); envPath = basename; } else if (envPath.includes('/') || envPath.includes('\\')) { // If it's a relative path with separators, get just the filename const basename = path.basename(envPath, '.bru'); envPath = basename; } else if (envPath.endsWith('.bru')) { // If it ends with .bru, remove the extension envPath = envPath.replace(/\.bru$/, ''); } // If just a name (no path separators, no extension), use as-is args.push('--env', envPath); } // Add environment variable overrides if (options.envVariables) { for (const [key, value] of Object.entries(options.envVariables)) { args.push('--env-var', `${key}=${value}`); } } if (options.testsOnly) { args.push('--tests-only'); } if (options.bail) { args.push('--bail'); } // Add reporter options for report generation if (options.reporterJson) { args.push('--reporter-json', options.reporterJson); } if (options.reporterJunit) { args.push('--reporter-junit', options.reporterJunit); } if (options.reporterHtml) { args.push('--reporter-html', options.reporterHtml); } try { // Get timeout configuration const configLoader = getConfigLoader(); const timeout = configLoader.getTimeout(); // Run Bruno CLI from within the collection directory const result = await execa(this.brunoCommand, args, { cwd: collectionPath, env: { ...process.env }, reject: false, timeout: timeout.collection }); // Read the JSON output file // Note: If custom reporters are used, Bruno CLI may not write to the temp file let jsonResult: any = null; const hasCustomReporters = options.reporterJson || options.reporterJunit || options.reporterHtml; if (!hasCustomReporters) { // Only try to read temp file if no custom reporters are specified try { const jsonContent = await fs.readFile(tempFile, 'utf-8'); jsonResult = JSON.parse(jsonContent); } catch (error) { console.error('Failed to read Bruno output file:', error); } } // Clean up temp file try { await fs.unlink(tempFile); } catch { // Ignore cleanup errors } return this.parseRunResult(result, jsonResult); } catch (error) { // Clean up temp file on error try { await fs.unlink(tempFile); } catch { // Ignore cleanup errors } throw this.handleError(error); } } /** * List all requests in a collection */ async listRequests(collectionPath: string): Promise<BrunoRequest[]> { try { // Check cache first const perfManager = getPerformanceManager(); const cached = perfManager.getCachedRequestList(collectionPath); if (cached) { console.error(`Using cached request list for: ${collectionPath}`); return cached; } // Check if the collection path exists const stats = await fs.stat(collectionPath); if (!stats.isDirectory()) { throw new Error(`Collection path is not a directory: ${collectionPath}`); } // Check if it's a valid Bruno collection (should have bruno.json or collection.bru) const hasCollectionFile = await this.hasCollectionFile(collectionPath); if (!hasCollectionFile) { throw new Error(`Not a valid Bruno collection: ${collectionPath}`); } // Find all .bru files in the collection const requests = await this.findBrunoRequests(collectionPath); // Cache the results perfManager.cacheRequestList(collectionPath, requests); return requests; } catch (error) { if ((error as any).code === 'ENOENT') { throw new Error(`Collection not found: ${collectionPath}`); } throw error; } } /** * Check if directory has a Bruno collection file */ private async hasCollectionFile(dirPath: string): Promise<boolean> { try { // Check for bruno.json or collection.bru const brunoJsonPath = path.join(dirPath, 'bruno.json'); const collectionBruPath = path.join(dirPath, 'collection.bru'); try { await fs.access(brunoJsonPath); return true; } catch { // Try collection.bru try { await fs.access(collectionBruPath); return true; } catch { return false; } } } catch { return false; } } /** * Find a specific request file by name */ private async findRequestFile(collectionPath: string, requestName: string): Promise<string | null> { const requests = await this.findBrunoRequests(collectionPath); // Try exact match first let request = requests.find(r => r.name === requestName); // Try case-insensitive match if (!request) { request = requests.find(r => r.name.toLowerCase() === requestName.toLowerCase()); } // Try partial match if (!request) { request = requests.find(r => r.name.includes(requestName)); } return request?.path || null; } /** * Recursively find all Bruno request files */ private async findBrunoRequests( dirPath: string, basePath: string = dirPath, requests: BrunoRequest[] = [] ): Promise<BrunoRequest[]> { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory() && !entry.name.startsWith('.')) { // Skip node_modules and other common non-request directories if (entry.name === 'node_modules' || entry.name === 'environments') { continue; } // Recursively search subdirectories await this.findBrunoRequests(fullPath, basePath, requests); } else if (entry.isFile() && entry.name.endsWith('.bru')) { // Skip collection.bru as it's not a request if (entry.name === 'collection.bru') { continue; } // Parse the .bru file for basic info const requestInfo = await this.parseBrunoFile(fullPath); const relativePath = path.relative(basePath, dirPath); requests.push({ name: path.basename(entry.name, '.bru'), folder: relativePath || undefined, path: fullPath, ...requestInfo }); } } return requests; } /** * Parse a Bruno request file for basic information */ private async parseBrunoFile(filePath: string): Promise<Partial<BrunoRequest>> { try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); const info: Partial<BrunoRequest> = {}; // Look for meta name first let inMeta = false; for (const line of lines) { if (line.trim() === 'meta {') { inMeta = true; continue; } if (inMeta && line.trim() === '}') { inMeta = false; continue; } if (inMeta) { const nameMatch = line.match(/^\s*name:\s*(.+)/); if (nameMatch) { info.name = nameMatch[1].trim(); } } // Look for method and URL const methodMatch = line.match(/^(get|post|put|delete|patch|head|options)\s*\{/i); if (methodMatch) { info.method = methodMatch[1].toUpperCase(); // Look for URL in the next few lines const urlIndex = lines.indexOf(line); for (let i = urlIndex + 1; i < Math.min(urlIndex + 5, lines.length); i++) { const urlMatch = lines[i].match(/^\s*url:\s*(.+)/); if (urlMatch) { info.url = urlMatch[1].trim(); break; } } } } return info; } catch { // If we can't parse the file, just return empty info return {}; } } /** * Parse the result from Bruno CLI execution */ private parseRunResult(result: any, jsonOutput?: any): BrunoRunResult { const runResult: BrunoRunResult = { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: result.exitCode || 0 }; // Use the JSON output if available (from file) let jsonResult = jsonOutput || this.tryParseJson(result.stdout); // Bruno CLI returns an array with a single object containing summary and results // Unwrap it if needed if (Array.isArray(jsonResult) && jsonResult.length > 0) { jsonResult = jsonResult[0]; } if (jsonResult) { // Extract summary information if (jsonResult.summary) { runResult.summary = { totalRequests: jsonResult.summary.totalRequests || jsonResult.summary.total || 0, passedRequests: jsonResult.summary.passedRequests || jsonResult.summary.passed || 0, failedRequests: jsonResult.summary.failedRequests || jsonResult.summary.failed || 0, totalDuration: jsonResult.summary.totalDuration || jsonResult.summary.duration || 0 }; } // Extract individual results with response data if (jsonResult.results && Array.isArray(jsonResult.results)) { runResult.results = jsonResult.results.map((r: any) => { const result: any = { name: r.suitename || r.name || r.test?.filename || 'Unknown', passed: r.error === null || r.error === undefined, status: r.response?.status || 0, duration: r.response?.responseTime || r.runtime || 0, error: r.error }; // Add request details if available if (r.request) { result.request = { method: r.request.method, url: r.request.url, headers: r.request.headers, body: r.request.body || r.request.data }; } // Add response details if available if (r.response) { result.response = { status: r.response.status, statusText: r.response.statusText, headers: r.response.headers, body: r.response.data || r.response.body, responseTime: r.response.responseTime }; } // Add test/assertion results (Bruno uses testResults and assertionResults) const testResults = r.testResults || r.tests || r.assertions; const assertionResults = r.assertionResults || []; const allTests = []; if (Array.isArray(testResults)) { allTests.push(...testResults.map((t: any) => ({ name: t.description || t.name || t.test, passed: t.status === 'pass' || t.passed === true, error: t.status === 'fail' ? t.error || t.message : undefined }))); } if (Array.isArray(assertionResults)) { allTests.push(...assertionResults.map((a: any) => ({ name: a.description || a.name, passed: a.status === 'pass' || a.passed === true, error: a.status === 'fail' ? a.error || a.message : undefined }))); } if (allTests.length > 0) { result.assertions = allTests; } return result; }); } // If results are in a different structure (check for 'items' or direct array) if (!runResult.results && jsonResult.items && Array.isArray(jsonResult.items)) { runResult.results = this.parseItems(jsonResult.items); } } return runResult; } /** * Try to parse JSON from string */ private tryParseJson(str: string): any { if (!str || !str.trim()) return null; try { // Try to parse as-is if (str.trim().startsWith('{') || str.trim().startsWith('[')) { return JSON.parse(str); } // Look for JSON in the output (sometimes there's extra text) const jsonMatch = str.match(/(\{[\s\S]*\}|[[\][\s\S]*\])/); if (jsonMatch) { return JSON.parse(jsonMatch[1]); } } catch { // Not JSON or parsing failed } return null; } /** * Parse items from alternative JSON structure */ private parseItems(items: any[]): any[] { return items.map((item: any) => { const result: any = { name: item.name || 'Unknown', passed: item.status === 'passed' || item.passed !== false, status: item.response?.status || 0, duration: item.duration || 0 }; if (item.request) { result.request = { method: item.request.method, url: item.request.url, headers: item.request.headers, body: item.request.body }; } if (item.response) { result.response = { status: item.response.status, statusText: item.response.statusText, headers: item.response.headers, body: item.response.body || item.response.data, responseTime: item.response.time || item.response.responseTime }; } if (item.tests) { result.assertions = item.tests.map((t: any) => ({ name: t.name || t.test, passed: t.passed || t.status === 'passed', error: t.error })); } return result; }); } /** * Discover Bruno collections in a directory */ async discoverCollections(searchPath: string, maxDepth: number = 5): Promise<string[]> { const perfManager = getPerformanceManager(); // Check cache first const cached = perfManager.getCachedCollectionDiscovery(searchPath); if (cached) { return cached; } const collections: string[] = []; const maxSearchDepth = Math.min(maxDepth, 10); // Cap at 10 for safety async function searchDirectory(dirPath: string, currentDepth: number) { if (currentDepth > maxSearchDepth) { return; } try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); // Check if this directory contains bruno.json const hasBrunoJson = entries.some(entry => entry.isFile() && entry.name === 'bruno.json' ); if (hasBrunoJson) { collections.push(dirPath); // Don't search subdirectories of a collection return; } // Recursively search subdirectories for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { const subPath = path.join(dirPath, entry.name); await searchDirectory(subPath, currentDepth + 1); } } } catch (error) { // Silently skip directories we can't read console.error(`Cannot read directory: ${dirPath}`, error); } } await searchDirectory(searchPath, 0); // Cache the results perfManager.cacheCollectionDiscovery(searchPath, collections); return collections; } /** * List environments in a collection */ async listEnvironments(collectionPath: string): Promise<Array<{ name: string; path: string; variables?: Record<string, string>; }>> { const perfManager = getPerformanceManager(); // Check cache first const cached = perfManager.getCachedEnvironmentList(collectionPath); if (cached) { return cached; } const environmentsPath = path.join(collectionPath, 'environments'); const environments: Array<{ name: string; path: string; variables?: Record<string, string> }> = []; try { const entries = await fs.readdir(environmentsPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.bru')) { const envName = path.basename(entry.name, '.bru'); const envPath = path.join(environmentsPath, entry.name); try { const content = await fs.readFile(envPath, 'utf-8'); const variables = this.parseEnvironmentVariables(content); environments.push({ name: envName, path: envPath, variables }); } catch (error) { // Include environment even if we can't parse it environments.push({ name: envName, path: envPath }); } } } } catch (error) { // Return empty array if environments directory doesn't exist return []; } // Cache the results perfManager.cacheEnvironmentList(collectionPath, environments); return environments; } /** * Validate an environment file */ async validateEnvironment(collectionPath: string, environmentName: string): Promise<{ valid: boolean; exists: boolean; errors: string[]; warnings: string[]; variables?: Record<string, string>; }> { const result = { valid: true, exists: false, errors: [] as string[], warnings: [] as string[], variables: undefined as Record<string, string> | undefined }; const envPath = path.join(collectionPath, 'environments', `${environmentName}.bru`); try { await fs.access(envPath); result.exists = true; } catch { result.valid = false; result.errors.push(`Environment file not found: ${envPath}`); return result; } try { const content = await fs.readFile(envPath, 'utf-8'); // Check basic structure if (!content.includes('vars {')) { result.warnings.push('Environment file does not contain a "vars {}" block'); } // Parse variables result.variables = this.parseEnvironmentVariables(content); // Check for common issues if (Object.keys(result.variables).length === 0) { result.warnings.push('No variables defined in environment'); } // Check for potentially sensitive data that might be hardcoded for (const [key, value] of Object.entries(result.variables)) { if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret') || key.toLowerCase().includes('token')) { if (value && !value.startsWith('{{') && !value.startsWith('$')) { result.warnings.push(`Variable "${key}" may contain hardcoded sensitive data`); } } } } catch (error) { result.valid = false; result.errors.push(`Failed to read environment file: ${error}`); } return result; } /** * Parse environment variables from .bru file content */ private parseEnvironmentVariables(content: string): Record<string, string> { const variables: Record<string, string> = {}; // Match vars { ... } block const varsMatch = content.match(/vars\s*\{([^}]*)\}/s); if (!varsMatch) { return variables; } const varsContent = varsMatch[1]; // Match variable assignments: name: value const varRegex = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+?)\s*$/gm; let match; while ((match = varRegex.exec(varsContent)) !== null) { const [, key, value] = match; variables[key] = value; } return variables; } /** * Get detailed information about a request */ async getRequestDetails(collectionPath: string, requestName: string): Promise<{ name: string; method: string; url: string; headers: Record<string, string>; body?: { type: string; content: string; }; auth: string; tests?: string[]; metadata: { type: string; seq?: number; }; }> { // Find the request file const requestFile = await this.findRequestFile(collectionPath, requestName); if (!requestFile) { throw new Error(`Request "${requestName}" not found in collection`); } // Read and parse the .bru file const content = await fs.readFile(requestFile, 'utf-8'); const details = { name: requestName, method: 'GET', url: '', headers: {} as Record<string, string>, body: undefined as { type: string; content: string } | undefined, auth: 'none', tests: [] as string[], metadata: { type: 'http', seq: undefined as number | undefined } }; // Parse metadata block const metaMatch = content.match(/meta\s*\{([\s\S]*?)\n\}/s); if (metaMatch) { const metaContent = metaMatch[1]; const nameMatch = metaContent.match(/name:\s*(.+)/); if (nameMatch) details.name = nameMatch[1].trim(); const typeMatch = metaContent.match(/type:\s*(.+)/); if (typeMatch) details.metadata.type = typeMatch[1].trim(); const seqMatch = metaContent.match(/seq:\s*(\d+)/); if (seqMatch) details.metadata.seq = parseInt(seqMatch[1]); } // Parse method block (get, post, put, patch, delete, etc.) const methodBlocks = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; for (const method of methodBlocks) { const methodRegex = new RegExp(`${method}\\s*\\{([\\s\\S]*?)\\n\\}`, 's'); const methodMatch = content.match(methodRegex); if (methodMatch) { details.method = method.toUpperCase(); const methodContent = methodMatch[1]; const urlMatch = methodContent.match(/url:\s*(.+)/); if (urlMatch) details.url = urlMatch[1].trim(); const authMatch = methodContent.match(/auth:\s*(.+)/); if (authMatch) details.auth = authMatch[1].trim(); break; } } // Parse headers block const headersMatch = content.match(/headers\s*\{([\s\S]*?)\n\}/s); if (headersMatch) { const headersContent = headersMatch[1]; const headerLines = headersContent.split('\n').filter(line => line.trim()); for (const line of headerLines) { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).trim(); const value = line.substring(colonIndex + 1).trim(); details.headers[key] = value; } } } // Parse body block const bodyTypeMatch = content.match(/body:(json|text|xml|formUrlEncoded|multipartForm|graphql|sparql|none)\s*\{/); if (bodyTypeMatch) { const bodyType = bodyTypeMatch[1]; const bodyRegex = new RegExp(`body:${bodyType}\\s*\\{([\\s\\S]*?)\\n\\}`, 's'); const bodyMatch = content.match(bodyRegex); if (bodyMatch) { details.body = { type: bodyType, content: bodyMatch[1].trim() }; } } // Parse tests block const testsMatch = content.match(/tests\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s); if (testsMatch) { const testsContent = testsMatch[1]; // Extract test function calls const testRegex = /test\s*\(\s*["']([^"']+)["']/g; let testMatch; while ((testMatch = testRegex.exec(testsContent)) !== null) { details.tests.push(testMatch[1]); } } return details; } /** * Validate a Bruno collection */ async validateCollection(collectionPath: string): Promise<{ valid: boolean; errors: string[]; warnings: string[]; summary: { hasBrunoJson: boolean; totalRequests: number; validRequests: number; invalidRequests: number; environments: number; }; }> { const result = { valid: true, errors: [] as string[], warnings: [] as string[], summary: { hasBrunoJson: false, totalRequests: 0, validRequests: 0, invalidRequests: 0, environments: 0 } }; // Check if collection directory exists try { await fs.access(collectionPath); } catch { result.valid = false; result.errors.push(`Collection directory not found: ${collectionPath}`); return result; } // Check for bruno.json const brunoJsonPath = path.join(collectionPath, 'bruno.json'); try { await fs.access(brunoJsonPath); result.summary.hasBrunoJson = true; // Validate bruno.json structure const brunoJsonContent = await fs.readFile(brunoJsonPath, 'utf-8'); try { const brunoJson = JSON.parse(brunoJsonContent); // Check required fields if (!brunoJson.version) { result.warnings.push('bruno.json missing "version" field'); } if (!brunoJson.name) { result.warnings.push('bruno.json missing "name" field'); } if (!brunoJson.type) { result.warnings.push('bruno.json missing "type" field'); } else if (brunoJson.type !== 'collection') { result.errors.push(`Invalid type in bruno.json: expected "collection", got "${brunoJson.type}"`); result.valid = false; } } catch (error) { result.errors.push(`Invalid JSON in bruno.json: ${error}`); result.valid = false; } } catch { result.errors.push('bruno.json not found in collection root'); result.valid = false; return result; } // Validate requests try { const requests = await this.listRequests(collectionPath); result.summary.totalRequests = requests.length; if (requests.length === 0) { result.warnings.push('Collection contains no requests'); } // Validate each request file for (const req of requests) { try { await this.getRequestDetails(collectionPath, req.name); result.summary.validRequests++; } catch (error) { result.summary.invalidRequests++; result.errors.push(`Invalid request "${req.name}": ${error}`); result.valid = false; } } } catch (error) { result.errors.push(`Failed to list requests: ${error}`); result.valid = false; } // Check for environments try { const environments = await this.listEnvironments(collectionPath); result.summary.environments = environments.length; if (environments.length === 0) { result.warnings.push('No environments found in collection'); } // Validate each environment for (const env of environments) { const validation = await this.validateEnvironment(collectionPath, env.name); if (!validation.valid) { result.warnings.push(`Environment "${env.name}" has issues: ${validation.errors.join(', ')}`); } if (validation.warnings.length > 0) { validation.warnings.forEach(w => result.warnings.push(`Environment "${env.name}": ${w}`)); } } } catch (error) { // Environments are optional, so this is just a warning result.warnings.push('Could not validate environments'); } return result; } /** * Handle errors from Bruno CLI */ private handleError(error: any): Error { // Check if it's an execa error by checking for specific properties if (error && typeof error === 'object' && 'stderr' in error) { if (error.stderr?.includes('command not found') || error.stderr?.includes('not recognized') || error.stderr?.includes('ENOENT')) { return new Error( `Bruno CLI not found at: ${this.brunoCommand}. ` + 'Please ensure Bruno CLI is installed by running: npm install' ); } if (error.stderr?.includes('You can run only at the root of a collection')) { return new Error( 'Invalid collection directory. Bruno CLI must be run from the root of a Bruno collection. ' + 'Ensure the directory contains a bruno.json or collection.bru file.' ); } if (error.stderr) { return new Error(`Bruno CLI error: ${error.stderr}`); } return new Error(`Bruno CLI failed: ${error.message || 'Unknown error'}`); } return error instanceof Error ? error : new Error(String(error)); } }

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/jcr82/bruno-mcp-server'

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