Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
github-api-client.ts10.1 kB
/** * Real GitHub API Client for Integration Testing * NO MOCKS - This performs actual GitHub API operations * * SECURITY NOTE: This is a test utility for E2E testing against controlled test repositories. * Unicode normalization (DMCP-SEC-004) is not required as this only interacts with * test data in controlled environments. The GitHub API handles its own input validation. */ import { TestEnvironment } from '../e2e/setup-test-env.js'; export interface GitHubFile { path: string; content: string; sha?: string; html_url?: string; } export interface GitHubCommit { sha: string; html_url: string; message: string; } export interface UploadResult { success: boolean; url?: string; commit?: GitHubCommit; error?: string; errorCode?: string; } /** * Real GitHub API client for testing */ export class GitHubTestClient { private baseUrl = 'https://api.github.com'; private headers: Record<string, string>; constructor(private config: TestEnvironment) { this.headers = { 'Authorization': `Bearer ${config.githubToken}`, 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'DollhouseMCP-QA-Test/1.0' }; } /** * Upload a file to GitHub (real API call) */ async uploadFile( filePath: string, content: string, message: string = 'QA test upload' ): Promise<UploadResult> { const [owner, repo] = this.config.testRepo.split('/'); const url = `${this.baseUrl}/repos/${owner}/${repo}/contents/${filePath}`; try { // Check if file exists first const existingFile = await this.getFile(filePath); const body: any = { message, content: Buffer.from(content).toString('base64'), branch: this.config.testBranch }; // If file exists, include SHA for update if (existingFile) { body.sha = existingFile.sha; } const response = await this.fetchWithRetry(url, { method: 'PUT', headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Unknown error' })); return { success: false, error: error.message, errorCode: this.mapErrorCode(response.status, error) }; } const data = await response.json(); // Extract URL with multiple fallbacks (matching the fix from session notes) let resultUrl = data.commit?.html_url; if (!resultUrl && data.content?.html_url) { resultUrl = data.content.html_url; } if (!resultUrl) { // Generate fallback URL resultUrl = `https://github.com/${owner}/${repo}/blob/${this.config.testBranch}/${filePath}`; } return { success: true, url: resultUrl, commit: data.commit ? { sha: data.commit.sha, html_url: data.commit.html_url || resultUrl, message: data.commit.message || message } : undefined }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error', errorCode: 'PORTFOLIO_SYNC_005' }; } } /** * Get a file from GitHub (real API call) * * NOTE: This method uses 'dollhouse-portfolio' instead of the configured test repo * because PortfolioRepoManager has a hardcoded PORTFOLIO_REPO_NAME = 'dollhouse-portfolio'. * This is a temporary workaround until PortfolioRepoManager is made configurable. * See: src/portfolio/PortfolioRepoManager.ts line 26 */ async getFile(filePath: string): Promise<GitHubFile | null> { const [owner] = this.config.testRepo.split('/'); // Use the hardcoded repo name from PortfolioRepoManager const repo = 'dollhouse-portfolio'; const url = `${this.baseUrl}/repos/${owner}/${repo}/contents/${filePath}`; try { const response = await this.fetchWithRetry(url, { headers: this.headers }); if (response.status === 404) { return null; } if (!response.ok) { throw new Error(`Failed to get file: ${response.status} ${response.statusText}`); } const data = await response.json(); // Decode content from base64 const content = data.content ? Buffer.from(data.content, 'base64').toString('utf-8') : ''; return { path: data.path, content, sha: data.sha, html_url: data.html_url }; } catch (error) { if (this.config.verboseLogging) { console.error('Error getting file:', error); } return null; } } /** * Delete a file from GitHub (for cleanup) * * NOTE: Uses 'dollhouse-portfolio' to match PortfolioRepoManager's hardcoded repo name */ async deleteFile(filePath: string, message: string = 'QA test cleanup'): Promise<boolean> { const [owner] = this.config.testRepo.split('/'); const repo = 'dollhouse-portfolio'; // Match PortfolioRepoManager's hardcoded value const url = `${this.baseUrl}/repos/${owner}/${repo}/contents/${filePath}`; try { // Get file SHA first const file = await this.getFile(filePath); if (!file || !file.sha) { return false; // File doesn't exist } const response = await this.fetchWithRetry(url, { method: 'DELETE', headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ message, sha: file.sha, branch: this.config.testBranch }) }); return response.ok; } catch (error) { if (this.config.verboseLogging) { console.error('Error deleting file:', error); } return false; } } /** * List files in a directory * * NOTE: Uses 'dollhouse-portfolio' to match PortfolioRepoManager's hardcoded repo name */ async listFiles(directory: string = ''): Promise<string[]> { const [owner] = this.config.testRepo.split('/'); const repo = 'dollhouse-portfolio'; // Match PortfolioRepoManager's hardcoded value const url = `${this.baseUrl}/repos/${owner}/${repo}/contents/${directory}`; try { const response = await this.fetchWithRetry(url, { headers: this.headers }); if (!response.ok) { return []; } const data = await response.json(); if (!Array.isArray(data)) { return []; } return data .filter((item: any) => item.type === 'file') .map((item: any) => item.path); } catch (error) { if (this.config.verboseLogging) { console.error('Error listing files:', error); } return []; } } /** * Verify a URL is accessible (not 404) */ async verifyUrl(url: string): Promise<boolean> { try { // Properly parse and validate the URL const parsedUrl = new URL(url); // For GitHub URLs, we can check via API if (parsedUrl.hostname === 'github.com' || parsedUrl.hostname === 'www.github.com') { // Extract path from URL const match = url.match(/github\.com\/[^/]+\/[^/]+\/blob\/[^/]+\/(.+)/); if (match) { const file = await this.getFile(match[1]); return file !== null; } } // For other URLs, do a HEAD request const response = await fetch(url, { method: 'HEAD' }); return response.ok; } catch { return false; } } /** * Get rate limit status */ async getRateLimit(): Promise<{ remaining: number; reset: Date }> { const response = await fetch(`${this.baseUrl}/rate_limit`, { headers: this.headers }); const data = await response.json(); return { remaining: data.rate.remaining, reset: new Date(data.rate.reset * 1000) }; } /** * Fetch with retry logic */ private async fetchWithRetry( url: string, options: RequestInit = {} ): Promise<Response> { let lastError: Error | null = null; for (let i = 0; i < this.config.retryAttempts; i++) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs); const response = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timeout); // Don't retry on client errors (4xx) if (response.status >= 400 && response.status < 500) { return response; } // Retry on server errors (5xx) or network issues if (response.ok || i === this.config.retryAttempts - 1) { return response; } lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); } catch (error) { lastError = error as Error; if (error instanceof Error && error.name === 'AbortError') { lastError = new Error(`Request timeout after ${this.config.timeoutMs}ms`); } } // Wait before retry with exponential backoff if (i < this.config.retryAttempts - 1) { const delay = this.config.rateLimitDelayMs * Math.pow(2, i); await new Promise(resolve => setTimeout(resolve, delay)); } } throw lastError || new Error('Request failed after retries'); } /** * Map HTTP status codes to error codes */ private mapErrorCode(status: number, error: any): string { if (status === 401) return 'PORTFOLIO_SYNC_001'; if (status === 404) return 'PORTFOLIO_SYNC_002'; if (status === 403) { if (error.message?.includes('rate limit')) { return 'PORTFOLIO_SYNC_006'; } return 'PORTFOLIO_SYNC_003'; } if (status === 422) return 'PORTFOLIO_SYNC_003'; if (status >= 500) return 'PORTFOLIO_SYNC_005'; return 'PORTFOLIO_SYNC_004'; } }

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/DollhouseMCP/DollhouseMCP'

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