feat(mcp): add MCP endpoint; fix TODOs (pkg.json name/version, NestJS Logger)

This commit is contained in:
2026-04-07 13:20:15 +03:00
parent 6f23c0f736
commit bf1b6249a9
10 changed files with 417 additions and 18 deletions
+2
View File
@@ -7,6 +7,7 @@ import { BrowserModule } from './browser/browser.module';
import { SessionEntity } from './session/session.entity';
import { EnvironmentEntity } from './environment/environment.entity';
import { EnvironmentModule } from './environment/environment.module';
import { McpModule } from './mcp/mcp.module';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { EnvironmentModule } from './environment/environment.module';
AuthModule,
BrowserModule,
EnvironmentModule,
McpModule,
],
controllers: [HealthController],
})
+1
View File
@@ -7,5 +7,6 @@ import { SessionModule } from '../session/session.module';
imports: [SessionModule],
controllers: [BrowserController],
providers: [BrowserService],
exports: [BrowserService],
})
export class BrowserModule {}
+11 -8
View File
@@ -1,31 +1,34 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { ValidationPipe } from '@nestjs/common';
import { Logger, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { name as pkgName, version as pkgVersion } from '../package.json';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
const config = app.get(ConfigService);
const port = config.get<number>('PORT', 3000);
const appName = config.get<string>('APP_NAME', 'liquio-qa-bot');
const appVersion = config.get<string>('APP_VERSION', '1.0.0');
const nodeEnv = config.get<string>('NODE_ENV', 'development');
const swaggerConfig = new DocumentBuilder()
.setTitle(appName)
.setDescription(`${appName} API`)
.setVersion(appVersion)
.setTitle(pkgName)
.setDescription(`${pkgName} API`)
.setVersion(pkgVersion)
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api', app, document);
await app.listen(port);
console.log(`Application running on port ${port}`);
console.log(`Swagger UI available at http://localhost:${port}/api`);
logger.log(`Application "${pkgName}" v${pkgVersion} running on port ${port} [${nodeEnv}]`);
logger.log(`Swagger UI available at http://localhost:${port}/api`);
logger.log(`MCP endpoint available at http://localhost:${port}/mcp`);
}
bootstrap();
+13
View File
@@ -0,0 +1,13 @@
import { All, Controller, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { McpService } from './mcp.service';
@Controller('mcp')
export class McpController {
constructor(private readonly mcpService: McpService) {}
@All()
handle(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.mcpService.handle(req, res);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { McpController } from './mcp.controller';
import { McpService } from './mcp.service';
import { AuthModule } from '../auth/auth.module';
import { SessionModule } from '../session/session.module';
import { EnvironmentModule } from '../environment/environment.module';
import { BrowserModule } from '../browser/browser.module';
@Module({
imports: [AuthModule, SessionModule, EnvironmentModule, BrowserModule],
controllers: [McpController],
providers: [McpService],
})
export class McpModule {}
+217
View File
@@ -0,0 +1,217 @@
import { Injectable } from '@nestjs/common';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import type { Request, Response } from 'express';
import { AuthService } from '../auth/auth.service';
import { SessionService } from '../session/session.service';
import { EnvironmentService } from '../environment/environment.service';
import type { EnvironmentUrls } from '../environment/environment.entity';
import { BrowserService } from '../browser/browser.service';
@Injectable()
export class McpService {
constructor(
private readonly authService: AuthService,
private readonly sessionService: SessionService,
private readonly environmentService: EnvironmentService,
private readonly browserService: BrowserService,
) {}
async handle(req: Request, res: Response): Promise<void> {
const server = new McpServer({ name: 'liquio-qa-bot', version: '1.0.0' });
// ── Auth ──────────────────────────────────────────────────────────────────
server.registerTool(
'list_keys',
{ description: 'List available key identifiers from the keys directory' },
async () => {
const keys = this.authService.listKeys();
return { content: [{ type: 'text' as const, text: JSON.stringify(keys) }] };
},
);
server.registerTool(
'login',
{
description: 'Log in using a file key against a named environment and store the session',
inputSchema: {
key: z.string().describe('Key identifier (filename without extension from keys/ dir)'),
environmentName: z.string().describe('Environment name to resolve login/cabinet URLs'),
sessionName: z.string().optional().describe('Session name to store credentials under. Auto-UUID if omitted.'),
},
},
async ({ key, environmentName, sessionName }) => {
const result = await this.authService.login(key, environmentName, sessionName);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
},
);
// ── Sessions ──────────────────────────────────────────────────────────────
server.registerTool(
'list_sessions',
{ description: 'List all stored sessions (id, sessionName, createdAt, updatedAt)' },
async () => {
const sessions = await this.sessionService.findAll();
return { content: [{ type: 'text' as const, text: JSON.stringify(sessions) }] };
},
);
server.registerTool(
'delete_session',
{
description: 'Delete a session by numeric ID',
inputSchema: {
id: z.number().int().describe('Session ID to delete'),
},
},
async ({ id }) => {
const sessions = await this.sessionService.findAll();
if (!sessions.find(s => s.id === id)) {
return { isError: true, content: [{ type: 'text' as const, text: `Session ${id} not found` }] };
}
await this.sessionService.remove(id);
return { content: [{ type: 'text' as const, text: `Session ${id} deleted` }] };
},
);
// ── Environments ──────────────────────────────────────────────────────────
server.registerTool(
'list_environments',
{ description: 'List all environments' },
async () => {
const envs = await this.environmentService.findAll();
return { content: [{ type: 'text' as const, text: JSON.stringify(envs) }] };
},
);
server.registerTool(
'get_environment',
{
description: 'Get an environment record by ID',
inputSchema: {
id: z.number().int().describe('Environment ID'),
},
},
async ({ id }) => {
try {
const env = await this.environmentService.findOne(id);
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] };
} catch {
return { isError: true, content: [{ type: 'text' as const, text: `Environment ${id} not found` }] };
}
},
);
server.registerTool(
'create_environment',
{
description: 'Create a new named environment with a set of URLs',
inputSchema: {
name: z.string().describe('Unique environment name, e.g. liquio-diia-stg'),
urls: z.record(z.string(), z.string()).describe('Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)'),
},
},
async ({ name, urls }) => {
try {
const env = await this.environmentService.create({ name, urls: urls as EnvironmentUrls });
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] };
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] };
}
},
);
server.registerTool(
'update_environment',
{
description: 'Update an existing environment (name and/or urls)',
inputSchema: {
id: z.number().int().describe('Environment ID to update'),
name: z.string().optional().describe('New name'),
urls: z.record(z.string(), z.string()).optional().describe('New URLs map'),
},
},
async ({ id, name, urls }) => {
try {
const env = await this.environmentService.update(id, { name, urls: urls as EnvironmentUrls | undefined });
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] };
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] };
}
},
);
server.registerTool(
'delete_environment',
{
description: 'Delete an environment by ID',
inputSchema: {
id: z.number().int().describe('Environment ID to delete'),
},
},
async ({ id }) => {
try {
await this.environmentService.remove(id);
return { content: [{ type: 'text' as const, text: `Environment ${id} deleted` }] };
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] };
}
},
);
// ── Browser ───────────────────────────────────────────────────────────────
server.registerTool(
'open_url',
{
description: 'Open a URL using a stored session and return the page title and content',
inputSchema: {
sessionName: z.string().describe('Session name to restore cookies and localStorage from'),
url: z.string().url().describe('URL to navigate to'),
readerMode: z.boolean().optional().describe('Extract readable plain text instead of raw HTML'),
},
},
async ({ sessionName, url, readerMode }) => {
try {
const result = await this.browserService.open(sessionName, url, readerMode ?? false);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] };
}
},
);
server.registerTool(
'exec_code',
{
description: 'Execute arbitrary Playwright JavaScript with `page` and `context` in scope',
inputSchema: {
sessionName: z.string().describe('Session name to restore'),
code: z.string().describe('JavaScript code body to execute (async-safe, may use `page` and `context`)'),
url: z.string().url().optional().describe('Optional URL to navigate to before running code'),
},
},
async ({ sessionName, code, url }) => {
try {
const result = await this.browserService.exec(sessionName, code, url);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] };
}
},
);
// ── Transport ─────────────────────────────────────────────────────────────
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless — no session management
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
}
}