We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jmagar/homelab-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
import { EventEmitter } from "../events/index.js";
import { getCurrentTimestamp } from "../utils/time.js";
import { ComposeService } from "./compose.js";
import { ComposeProjectCache } from "./compose-cache.js";
import { ComposeDiscovery } from "./compose-discovery.js";
import { ComposeProjectLister } from "./compose-project-lister.js";
import { ComposeScanner } from "./compose-scanner.js";
import { ContainerHostMapCache } from "./container-host-map-cache.js";
import { DockerService } from "./docker/index.js";
import { FileService } from "./file-service.js";
import type { IHostConfigRepository } from "./host-config-repository.js";
import { HostConfigRepository } from "./host-config-repository.js";
import type {
DockerOperations,
IComposeProjectLister,
IComposeService,
IFileService,
ILocalExecutorService,
ISSHConnectionPool,
ISSHService,
} from "./interfaces.js";
import { LocalExecutorService } from "./local-executor.js";
import { SSHConnectionPoolImpl } from "./ssh-pool.js";
import { SSHService } from "./ssh-service.js";
/**
* Service lifecycle state
*/
export enum LifecycleState {
/** Container created but not initialized */
CREATED = "created",
/** Initialization in progress */
INITIALIZING = "initializing",
/** Fully initialized and ready */
READY = "ready",
/** Shutdown initiated */
SHUTTING_DOWN = "shutting_down",
/** Fully shut down */
SHUTDOWN = "shutdown",
}
/**
* Health check status for a DI-managed service dependency
*/
export interface ServiceContainerHealth {
/** Service name */
name: string;
/** Is service healthy? */
healthy: boolean;
/** Optional error message if unhealthy */
message?: string;
/** Response time in milliseconds (if applicable) */
responseTime?: number;
}
/**
* Service container for dependency injection.
* Manages service lifecycle and dependencies.
*
* Lifecycle phases:
* 1. CREATED - Container instantiated
* 2. INITIALIZING - Services being initialized (async setup)
* 3. READY - All services ready, accepting requests
* 4. SHUTTING_DOWN - Graceful shutdown in progress
* 5. SHUTDOWN - All services stopped
*
* Dependency chain (no circular dependencies):
* - EventEmitter (no dependencies) - application-wide event bus
* - LocalExecutorService (no dependencies)
* - SSHConnectionPool (no dependencies)
* - HostConfigRepository (no dependencies)
* - SSHService (requires SSHConnectionPool)
* - ComposeProjectLister (requires SSHService, LocalExecutorService)
* - ComposeProjectCache (no dependencies)
* - ComposeScanner (requires SSHService, LocalExecutorService)
* - ComposeDiscovery (requires ComposeProjectLister, ComposeProjectCache, ComposeScanner)
* - ComposeService (requires SSHService, LocalExecutorService, ComposeDiscovery as IComposePathResolver)
* - FileService (requires SSHService)
* - DockerService (no dependencies)
* - HostConfigs (loaded once at startup with 60s TTL cache)
* - ContainerHostMapCache (no dependencies)
*/
export class ServiceContainer {
private dockerService?: DockerOperations;
private sshService?: ISSHService;
private composeService?: IComposeService;
private sshPool?: ISSHConnectionPool;
private fileService?: IFileService;
private localExecutor?: ILocalExecutorService;
private composeProjectLister?: IComposeProjectLister;
private composeCache?: ComposeProjectCache;
private composeScanner?: ComposeScanner;
private composeDiscovery?: ComposeDiscovery;
private hostConfigRepository?: IHostConfigRepository;
private containerHostMapCache?: ContainerHostMapCache;
private eventEmitter: EventEmitter = new EventEmitter();
/** Current lifecycle state */
private lifecycleState: LifecycleState = LifecycleState.CREATED;
/** Shutdown timeout in milliseconds */
private shutdownTimeout = 30000; // 30 seconds
/**
* Get Docker service instance (lazy initialization)
*/
getDockerService(): DockerOperations {
if (!this.dockerService) this.dockerService = new DockerService();
return this.dockerService;
}
/**
* Set Docker service instance (for testing/overrides)
*/
setDockerService(service: DockerOperations): void {
this.dockerService = service;
}
/**
* Get SSH connection pool instance (lazy initialization)
*/
getSSHConnectionPool(): ISSHConnectionPool {
if (!this.sshPool) this.sshPool = new SSHConnectionPoolImpl();
return this.sshPool;
}
/**
* Set SSH connection pool instance (for testing/overrides)
*/
setSSHConnectionPool(pool: ISSHConnectionPool): void {
this.sshPool = pool;
}
/**
* Get SSH service instance (lazy initialization with dependencies)
*/
getSSHService(): ISSHService {
if (!this.sshService) this.sshService = new SSHService(this.getSSHConnectionPool());
return this.sshService;
}
/**
* Set SSH service instance (for testing/overrides)
*/
setSSHService(service: ISSHService): void {
this.sshService = service;
}
/**
* Get Local executor service instance (lazy initialization)
*/
getLocalExecutor(): ILocalExecutorService {
if (!this.localExecutor) this.localExecutor = new LocalExecutorService();
return this.localExecutor;
}
/**
* Set Local executor service instance (for testing/overrides)
*/
setLocalExecutor(service: ILocalExecutorService): void {
this.localExecutor = service;
}
/**
* Get Compose project lister instance (lazy initialization).
* Standalone service for `docker compose ls` - used by ComposeDiscovery
* to break the former circular dependency.
*/
getComposeProjectLister(): IComposeProjectLister {
if (!this.composeProjectLister) {
this.composeProjectLister = new ComposeProjectLister(
this.getSSHService(),
this.getLocalExecutor()
);
}
return this.composeProjectLister;
}
/**
* Get Compose service instance (lazy initialization with dependencies).
* ComposeDiscovery is injected as path resolver via constructor (no setter injection).
*/
getComposeService(): IComposeService {
if (!this.composeService) {
this.composeService = new ComposeService(
this.getSSHService(),
this.getLocalExecutor(),
this.getComposeDiscovery()
);
}
return this.composeService;
}
/**
* Set Compose service instance (for testing/overrides)
*/
setComposeService(service: IComposeService): void {
this.composeService = service;
}
/**
* Get File service instance (lazy initialization with dependencies)
*/
getFileService(): IFileService {
if (!this.fileService) this.fileService = new FileService(this.getSSHService());
return this.fileService;
}
/**
* Set File service instance (for testing/overrides)
*/
setFileService(service: IFileService): void {
this.fileService = service;
}
/**
* Get Compose project cache instance (lazy initialization)
*/
getComposeCache(): ComposeProjectCache {
if (!this.composeCache) {
this.composeCache = new ComposeProjectCache();
}
return this.composeCache;
}
/**
* Get Compose scanner instance (lazy initialization with dependencies)
*/
getComposeScanner(): ComposeScanner {
if (!this.composeScanner) {
this.composeScanner = new ComposeScanner(this.getSSHService(), this.getLocalExecutor());
}
return this.composeScanner;
}
/**
* Get Compose discovery instance (lazy initialization with dependencies).
* Uses ComposeProjectLister (not ComposeService) to avoid circular dependency.
* Dependency chain: ProjectLister -> Discovery -> ComposeService (linear, no cycle).
*/
getComposeDiscovery(): ComposeDiscovery {
if (!this.composeDiscovery) {
this.composeDiscovery = new ComposeDiscovery(
this.getComposeProjectLister(),
this.getComposeCache(),
this.getComposeScanner()
);
}
return this.composeDiscovery;
}
/**
* Get Host config repository instance (lazy initialization)
*/
getHostConfigRepository(): IHostConfigRepository {
if (!this.hostConfigRepository) {
// Enable cache by default to avoid 5-50ms per-request file I/O
this.hostConfigRepository = new HostConfigRepository({ enableCache: true });
}
return this.hostConfigRepository;
}
/**
* Set Host config repository instance (for testing/overrides)
*/
setHostConfigRepository(repository: IHostConfigRepository): void {
this.hostConfigRepository = repository;
}
/**
* Get Container host map cache instance (lazy initialization)
*/
getContainerHostMapCache(): ContainerHostMapCache {
if (!this.containerHostMapCache) this.containerHostMapCache = new ContainerHostMapCache();
return this.containerHostMapCache;
}
/**
* Set Container host map cache instance (for testing/overrides)
*/
setContainerHostMapCache(cache: ContainerHostMapCache): void {
this.containerHostMapCache = cache;
}
/**
* Get EventEmitter instance (application-wide event bus).
* Use this to emit or subscribe to application events.
*/
getEventEmitter(): EventEmitter {
return this.eventEmitter;
}
/**
* Get current lifecycle state
*/
getLifecycleState(): LifecycleState {
return this.lifecycleState;
}
/**
* Check if container is ready to accept requests
*/
isReady(): boolean {
return this.lifecycleState === LifecycleState.READY;
}
/**
* Initialize container and perform async service setup
*
* Performs:
* 1. Preload host configurations
* 2. Start SSH connection pool health checks
* 3. Emit lifecycle events
*
* @throws {Error} If already initialized or shutting down
*
* @example
* ```typescript
* const container = new ServiceContainer();
* await container.initialize();
* // Container is now ready to use
* ```
*/
async initialize(): Promise<void> {
if (this.lifecycleState !== LifecycleState.CREATED) {
throw new Error(
`Cannot initialize: container is ${this.lifecycleState}. Must be in CREATED state.`
);
}
this.lifecycleState = LifecycleState.INITIALIZING;
this.eventEmitter.emit({
type: "container_state_changed",
containerId: "service-container",
action: "start",
host: "local",
timestamp: getCurrentTimestamp(),
});
try {
// Preload host configurations (enables caching)
const hostRepo = this.getHostConfigRepository();
await hostRepo.loadHosts();
// SSH pool starts health checks automatically when first connection is created
// No explicit initialization needed
this.lifecycleState = LifecycleState.READY;
this.eventEmitter.emit({
type: "container_state_changed",
containerId: "service-container",
action: "start",
host: "local",
timestamp: getCurrentTimestamp(),
});
} catch (error) {
this.lifecycleState = LifecycleState.CREATED; // Reset to created on failure
throw new Error(
`Container initialization failed: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error }
);
}
}
/**
* Perform health checks on all initialized services
*
* Returns health status for each service. Useful for monitoring
* and graceful degradation.
*
* @returns Array of service health statuses
*
* @example
* ```typescript
* const health = await container.healthCheck();
* const unhealthy = health.filter(h => !h.healthy);
* if (unhealthy.length > 0) {
* console.error('Unhealthy services:', unhealthy);
* }
* ```
*/
async healthCheck(): Promise<ServiceContainerHealth[]> {
const results: ServiceContainerHealth[] = [];
// Check SSH connection pool
if (this.sshPool) {
const startTime = Date.now();
try {
const poolHealth = this.sshPool.getHealth();
results.push({
name: "SSHConnectionPool",
healthy: poolHealth.healthy,
message: poolHealth.healthy ? undefined : "Pool unhealthy or has failed connections",
responseTime: Date.now() - startTime,
});
} catch (error) {
results.push({
name: "SSHConnectionPool",
healthy: false,
message: error instanceof Error ? error.message : String(error),
responseTime: Date.now() - startTime,
});
}
}
// Check host config repository (can it load hosts?)
if (this.hostConfigRepository) {
const startTime = Date.now();
try {
const hosts = await this.hostConfigRepository.loadHosts();
results.push({
name: "HostConfigRepository",
healthy: hosts.length > 0,
message: hosts.length === 0 ? "No hosts configured" : undefined,
responseTime: Date.now() - startTime,
});
} catch (error) {
results.push({
name: "HostConfigRepository",
healthy: false,
message: error instanceof Error ? error.message : String(error),
responseTime: Date.now() - startTime,
});
}
}
// Check Docker service (is it instantiated?)
if (this.dockerService) {
results.push({
name: "DockerService",
healthy: true,
message: "Service instantiated",
});
}
// Check event emitter (listener count as health indicator)
const eventTypes: import("../events/types.js").EventType[] = [
"cache_invalidated",
"container_state_changed",
"compose_operation",
];
const listenerCount = eventTypes.reduce(
(sum, eventType) => sum + this.eventEmitter.listenerCount(eventType),
0
);
results.push({
name: "EventEmitter",
healthy: true,
message: `${listenerCount} active listeners`,
});
return results;
}
/**
* Gracefully shut down container and all services
*
* Performs ordered shutdown:
* 1. Stop accepting new requests (set state to SHUTTING_DOWN)
* 2. Wait for in-flight operations (via timeout)
* 3. Close all connections
* 4. Clear caches
* 5. Emit shutdown complete event
*
* @param timeout - Maximum time to wait for graceful shutdown (ms)
* @throws {Error} If shutdown times out
*
* @example
* ```typescript
* // Graceful shutdown with 30s timeout
* await container.shutdown();
*
* // Custom timeout
* await container.shutdown(10000); // 10 seconds
* ```
*/
async shutdown(timeout: number = this.shutdownTimeout): Promise<void> {
if (this.lifecycleState === LifecycleState.SHUTDOWN) {
return; // Already shut down
}
if (this.lifecycleState === LifecycleState.SHUTTING_DOWN) {
throw new Error("Shutdown already in progress");
}
this.lifecycleState = LifecycleState.SHUTTING_DOWN;
this.eventEmitter.emit({
type: "container_state_changed",
containerId: "service-container",
action: "stop",
host: "local",
timestamp: getCurrentTimestamp(),
});
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
// Create timeout promise with clearable timer to prevent leaks
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`Shutdown timed out after ${timeout}ms`)),
timeout
);
});
// Perform cleanup with timeout (closes connections, clears caches)
await Promise.race([this.performCleanup(), timeoutPromise]);
this.lifecycleState = LifecycleState.SHUTDOWN;
// Emit final shutdown event BEFORE removing listeners so subscribers
// can observe the shutdown completion
this.eventEmitter.emit({
type: "container_state_changed",
containerId: "service-container",
action: "stop",
host: "local",
timestamp: getCurrentTimestamp(),
});
// Now safe to remove all listeners
this.eventEmitter.removeAllListeners();
} catch (error) {
// Even if timeout occurs, mark as shutdown and clean up listeners
this.lifecycleState = LifecycleState.SHUTDOWN;
this.eventEmitter.removeAllListeners();
throw error;
} finally {
// Clear the timeout timer to prevent leaks and unhandled rejections
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
}
/**
* Close connections and clear caches without removing event listeners.
* Used internally by shutdown() which handles listener cleanup after
* emitting the final shutdown event.
*/
private async performCleanup(): Promise<void> {
if (this.sshPool) await this.sshPool.closeAll();
if (this.dockerService) {
this.dockerService.clearClients();
}
if (this.hostConfigRepository) {
this.hostConfigRepository.clearCache();
}
}
/**
* Cleanup all services and close connections.
* Call during shutdown to ensure clean termination.
*
* @deprecated Use shutdown() for graceful shutdown with timeout.
* This method is kept for backward compatibility but performs immediate cleanup.
*/
async cleanup(): Promise<void> {
await this.performCleanup();
this.eventEmitter.removeAllListeners();
}
}
/**
* Create a default service container with lazy initialization
*/
export function createDefaultContainer(): ServiceContainer {
return new ServiceContainer();
}