@vessl-ai/mcpctl-control-plane
Version:
Vessl MCP Control Plane service for managing distributed jobs.
426 lines (375 loc) • 13.2 kB
text/typescript
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TransportType } from '@vessl-ai/mcpctl-shared/types/common';
import { SecretRef } from '@vessl-ai/mcpctl-shared/types/domain/secret';
import {
ServerInstance,
ServerInstanceStatus,
ServerRunSpec,
} from '@vessl-ai/mcpctl-shared/types/domain/server';
import {
generateServerInstanceId,
generateServerRunSpecId,
} from '@vessl-ai/mcpctl-shared/util';
import { ChildProcess, spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { AppCacheService } from '../cache/appcache.service';
import { SecretService } from '../secret/secret.service';
import { ServerCacheKeys } from '../types/cache';
import { findFreePort } from '../util/network';
export class ServerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ServerService.name);
constructor(
private readonly appCacheService: AppCacheService,
private readonly configService: ConfigService,
private readonly secretService: SecretService,
) {}
private instances: Record<string, ServerInstance> = {};
async onModuleInit() {
this.logger.log('Initializing server service...');
// load all instances from the cache
const instances = await this.appCacheService.get<
Record<string, ServerInstance>
>(ServerCacheKeys.INSTANCE);
if (instances) {
this.instances = instances;
}
}
async onModuleDestroy() {
this.logger.log('Disposing server service...');
await this.dispose();
}
private async getServerInstances(): Promise<Record<string, ServerInstance>> {
if (Object.keys(this.instances).length > 0) {
return this.instances;
}
const instances = await this.appCacheService.get<
Record<string, ServerInstance>
>(ServerCacheKeys.INSTANCE);
this.instances = instances ?? {};
return this.instances;
}
private async setServerInstances(instances: Record<string, ServerInstance>) {
this.instances = instances;
await this.appCacheService.set(ServerCacheKeys.INSTANCE, this.instances);
}
createLogFile(params: { instanceId: string }): Promise<{ logFile: string }> {
const { instanceId } = params;
const logDir = this.configService.get('app.logDir');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const logFile = path.join(logDir, `${instanceId}.log`);
return Promise.resolve({ logFile });
}
// Start a server
async start(runSpec: ServerRunSpec): Promise<ServerInstance> {
// Assign ID to the run spec
runSpec.id = generateServerRunSpecId();
// 1. start a mcp server with the run config
let instance: ServerInstance = await this.initializeServerInstance(runSpec);
instance = await this.startServer(instance);
// 2. save the server spec and instance to the cache
this.instances[instance.id] = instance;
await this.upsertRunSpecList(runSpec);
await this.upsertInstanceList(instance);
this.logger.debug(
`Started server ${instance.id} with spec ${JSON.stringify(runSpec)}`,
);
return this.sanitizeResponseInstance(instance);
}
private async resolveSecret(
secrets: Record<string, SecretRef> | undefined,
): Promise<Record<string, string>> {
if (!secrets) {
return {};
}
const resolvedSecret = {};
for (const [key, secretRef] of Object.entries(secrets)) {
const secret = await this.secretService.get(
secretRef.source,
secretRef.key,
);
resolvedSecret[key] = secret;
}
return resolvedSecret;
}
private async initializeServerInstance(
runSpec: ServerRunSpec,
): Promise<ServerInstance> {
// 1. start a mcp server with supergateway wrapper
const port = runSpec.transport.port ?? (await findFreePort());
const serverInstance: ServerInstance = {
id: generateServerInstanceId(),
name: runSpec.name,
runSpec,
status: ServerInstanceStatus.Running,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
host: runSpec.transport.host ?? 'localhost',
port,
transport: runSpec.transport,
};
const { logFile } = await this.createLogFile({
instanceId: serverInstance.id,
});
const resolvedSecret = await this.resolveSecret(
serverInstance.runSpec.secrets,
);
this.logger.debug(
`Original secret: ${JSON.stringify(serverInstance.runSpec.secrets)}, Resolved secret: ${JSON.stringify(
resolvedSecret,
)}`,
);
const env: Record<string, string> = {
...serverInstance.runSpec.env,
...resolvedSecret,
};
if (process.env.PATH) {
env['PATH'] = process.env.PATH; // add PATH for launchd, otherwise npx/node/npm will not be found!
}
serverInstance.logsPath = logFile;
serverInstance.env = env;
return serverInstance;
}
private async startServer(serverInstance: ServerInstance) {
let child: ChildProcess | undefined;
if (serverInstance.transport.type === TransportType.Stdio) {
child = await this.startStdioServer(serverInstance);
} else if (serverInstance.transport.type === TransportType.Sse) {
child = await this.startSseServer(serverInstance);
} else if (serverInstance.transport.type === TransportType.StreamableHttp) {
child = await this.startStreamableHttpServer(serverInstance);
}
if (!child) {
throw new Error(`Failed to start server ${serverInstance.id}`);
}
this.logger.debug(
`Started server ${serverInstance.id} with args: ${child.spawnargs}, env: ${JSON.stringify(
serverInstance.env,
)}`,
);
child.on('error', (error) => {
this.logger.error(
`Failed to start server ${serverInstance.id}: ${error}`,
);
});
if (serverInstance.logsPath) {
const logFileStream = fs.createWriteStream(serverInstance.logsPath);
if (child.stdout) {
child.stdout.pipe(logFileStream);
}
if (child.stderr) {
child.stderr.pipe(logFileStream);
}
}
child.on('close', async (code, signal) => {
this.logger.log(`Server ${serverInstance.id} closed with code ${code}`);
const instance = (await this.getServerInstances())[serverInstance.id];
if (instance) {
instance.status = ServerInstanceStatus.Stopped;
instance.updatedAt = new Date().toISOString();
await this.setServerInstances(this.instances);
await this.deleteRunSpecList(instance.runSpec);
}
this.logger.log(`Server ${serverInstance.id} stopped`);
});
serverInstance.processId = child.pid;
serverInstance.processHandle = child;
return serverInstance;
}
private async startStdioServer(
serverInstance: ServerInstance,
): Promise<ChildProcess> {
const child = spawn(
'npx',
[
'-y',
'supergateway',
'--stdio',
serverInstance.runSpec.command,
'--port',
serverInstance.port.toString(),
'--baseUrl',
`http://${serverInstance.host}:${serverInstance.port}`,
'--ssePath',
'/sse',
'--messagePath',
'/message',
],
{
env: serverInstance.env,
},
);
serverInstance.transport = {
type: TransportType.Sse,
};
serverInstance.connectionUrl = `http://${serverInstance.host}:${serverInstance.port}/sse`;
return child;
}
private async startSseServer(
serverInstance: ServerInstance,
): Promise<ChildProcess> {
const [command, ...args] = serverInstance.runSpec.command.split(' ');
const child = spawn(command, args, {
env: serverInstance.env,
});
serverInstance.transport = {
type: TransportType.Sse,
};
serverInstance.connectionUrl = `http://${serverInstance.host}:${serverInstance.port}/sse`;
return child;
}
private async startStreamableHttpServer(
serverInstance: ServerInstance,
): Promise<ChildProcess> {
// TODO: Implement start logic
throw new Error('Not implemented');
}
private async upsertRunSpecList(runSpec: ServerRunSpec) {
const runSpecList = await this.appCacheService.get<ServerRunSpec[]>(
ServerCacheKeys.RUN_SPEC,
);
if (!runSpecList) {
await this.appCacheService.set(ServerCacheKeys.RUN_SPEC, [runSpec]);
} else {
runSpecList.push(runSpec);
await this.appCacheService.set(ServerCacheKeys.RUN_SPEC, runSpecList);
}
}
private async upsertInstanceList(instance: ServerInstance) {
const instances = await this.getServerInstances();
if (Object.keys(instances).length === 0) {
await this.setServerInstances({
[instance.id]: instance,
});
} else {
instances[instance.id] = instance;
await this.setServerInstances(instances);
}
}
private async deleteRunSpecList(runSpec: ServerRunSpec) {
const runSpecList = await this.appCacheService.get<ServerRunSpec[]>(
ServerCacheKeys.RUN_SPEC,
);
if (runSpecList) {
runSpecList.splice(runSpecList.indexOf(runSpec), 1);
await this.appCacheService.set(ServerCacheKeys.RUN_SPEC, runSpecList);
}
}
private async deleteInstanceList(instance: ServerInstance) {
const instances = await this.getServerInstances();
if (instances) {
delete this.instances[instance.id];
await this.setServerInstances(this.instances);
}
}
// Stop a server
async stopInstance(name: string): Promise<ServerInstance> {
const instance = await this.getInstanceByName(name);
if (!instance) {
throw new Error(`Instance ${name} not found`);
}
const instanceProcessHandle = this.instances[instance.id].processHandle;
if (instanceProcessHandle) {
instanceProcessHandle.kill();
} else {
if (instance.processId) {
try {
process.kill(instance.processId);
} catch (error) {
this.logger.error(`Failed to kill process ${instance.processId}`);
}
}
}
instance.status = ServerInstanceStatus.Stopped;
await this.upsertInstanceList(instance);
return this.sanitizeResponseInstance(instance);
}
// Restart a server
async restartInstance(name: string): Promise<ServerInstance> {
const instance = await this.stopInstance(name);
return this.start(instance.runSpec);
}
// List all server instances
async listInstances(): Promise<ServerInstance[]> {
const instances = await this.getServerInstances();
if (!instances) {
return [];
}
this.logger.debug(`Listing servers: ${JSON.stringify(instances)}`);
return this.sanitizeResponseInstanceList(Object.values(instances));
}
// List all server run specs
async listRunSpecs(): Promise<ServerRunSpec[]> {
const runSpecs = await this.appCacheService.get<ServerRunSpec[]>(
ServerCacheKeys.RUN_SPEC,
);
this.logger.debug(`Listing servers: ${JSON.stringify(runSpecs)}`);
return runSpecs ?? [];
}
async getInstanceByName(name: string): Promise<ServerInstance | undefined> {
const instances = await this.getServerInstances();
const result = Object.values(instances).find(
(instance) => instance.name === name,
);
return result ? this.sanitizeResponseInstance(result) : undefined;
}
async getRunSpecByName(name: string): Promise<ServerRunSpec | undefined> {
const runSpecs = await this.appCacheService.get<ServerRunSpec[]>(
ServerCacheKeys.RUN_SPEC,
);
return runSpecs?.find((runSpec) => runSpec.name === name);
}
async dispose() {
// stop all instances
this.logger.log('Stopping all instances...');
const instances = await this.listInstances();
for (const instance of instances) {
await this.stopInstance(instance.name);
}
this.logger.log('All instances stopped');
// backup the cache
this.logger.log('Backing up cache...');
const cache = await this.appCacheService.get<ServerRunSpec[]>(
ServerCacheKeys.RUN_SPEC,
);
this.logger.log('Cache backed up');
// clear the cache
this.logger.log('Clearing cache...');
await this.appCacheService.clear();
}
private sanitizeResponseInstance(instance: ServerInstance): ServerInstance {
return {
...instance,
processHandle: undefined,
};
}
private sanitizeResponseInstanceList(
instances: ServerInstance[],
): ServerInstance[] {
return instances.map((instance) => this.sanitizeResponseInstance(instance));
}
async removeServerInstance(serverName: string) {
const instances = await this.getServerInstances();
const instance = Object.values(instances).find(
(instance) => instance.name === serverName,
);
if (!instance) {
throw new Error(`Server ${serverName} not found`);
}
if (instance.status === ServerInstanceStatus.Running) {
throw new Error(`Server ${serverName} is running, please stop it first`);
}
delete instances[serverName];
await this.setServerInstances(instances);
await this.deleteRunSpecList(instance.runSpec);
}
}