From bf1b6249a9f608081c46cc9dfc463f23bb2aa119 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 7 Apr 2026 13:20:15 +0300 Subject: [PATCH] feat(mcp): add MCP endpoint; fix TODOs (pkg.json name/version, NestJS Logger) --- .env.example | 2 - package-lock.json | 160 ++++++++++++++++++++++++- package.json | 4 +- src/app.module.ts | 2 + src/browser/browser.module.ts | 1 + src/main.ts | 19 +-- src/mcp/mcp.controller.ts | 13 ++ src/mcp/mcp.module.ts | 14 +++ src/mcp/mcp.service.ts | 217 ++++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 10 files changed, 417 insertions(+), 18 deletions(-) create mode 100644 src/mcp/mcp.controller.ts create mode 100644 src/mcp/mcp.module.ts create mode 100644 src/mcp/mcp.service.ts diff --git a/.env.example b/.env.example index 7dec620..d316b63 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,5 @@ NODE_ENV=development PORT=3000 -APP_NAME=liquio-qa-bot -APP_VERSION=1.0.0 # Directory (relative to cwd) containing *.json key descriptors KEYS_DIR=keys diff --git a/package-lock.json b/package-lock.json index dac9efb..a1b16e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", "@nestjs/common": "^11.1.18", "@nestjs/config": "^4.0.3", @@ -24,7 +25,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "swagger-ui-express": "^5.0.1", - "typeorm": "^0.3.28" + "typeorm": "^0.3.28", + "zod": "^4.3.6" }, "devDependencies": { "@nestjs/cli": "^11.0.17", @@ -400,6 +402,18 @@ } } }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -922,6 +936,46 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@mozilla/readability": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", @@ -1650,7 +1704,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1667,7 +1720,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -2811,6 +2863,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2863,11 +2936,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2887,7 +2977,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -3269,6 +3358,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3366,6 +3464,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3520,6 +3627,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3590,9 +3706,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4197,6 +4318,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -6041,6 +6171,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 5259f37..bdf7f93 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "author": "", "license": "ISC", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", "@nestjs/common": "^11.1.18", "@nestjs/config": "^4.0.3", @@ -28,7 +29,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "swagger-ui-express": "^5.0.1", - "typeorm": "^0.3.28" + "typeorm": "^0.3.28", + "zod": "^4.3.6" }, "devDependencies": { "@nestjs/cli": "^11.0.17", diff --git a/src/app.module.ts b/src/app.module.ts index ad6e97c..5a514f1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], }) diff --git a/src/browser/browser.module.ts b/src/browser/browser.module.ts index 5c2e562..8ff9d42 100644 --- a/src/browser/browser.module.ts +++ b/src/browser/browser.module.ts @@ -7,5 +7,6 @@ import { SessionModule } from '../session/session.module'; imports: [SessionModule], controllers: [BrowserController], providers: [BrowserService], + exports: [BrowserService], }) export class BrowserModule {} diff --git a/src/main.ts b/src/main.ts index eb38dbf..4323dca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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('PORT', 3000); - const appName = config.get('APP_NAME', 'liquio-qa-bot'); - const appVersion = config.get('APP_VERSION', '1.0.0'); + const nodeEnv = config.get('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(); diff --git a/src/mcp/mcp.controller.ts b/src/mcp/mcp.controller.ts new file mode 100644 index 0000000..576c84e --- /dev/null +++ b/src/mcp/mcp.controller.ts @@ -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 { + return this.mcpService.handle(req, res); + } +} diff --git a/src/mcp/mcp.module.ts b/src/mcp/mcp.module.ts new file mode 100644 index 0000000..fda2d92 --- /dev/null +++ b/src/mcp/mcp.module.ts @@ -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 {} diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts new file mode 100644 index 0000000..cc979ac --- /dev/null +++ b/src/mcp/mcp.service.ts @@ -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 { + 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); + } +} diff --git a/tsconfig.json b/tsconfig.json index 3447c11..dd83dae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "resolveJsonModule": true } }